feat(gatekeeper): upsize subscription on workspace role change
This commit is contained in:
@@ -2,8 +2,8 @@
|
||||
import {
|
||||
CreateCheckoutSession,
|
||||
GetSubscriptionData,
|
||||
SubscriptionData,
|
||||
WorkspaceSubscription
|
||||
ReconcileSubscriptionData,
|
||||
SubscriptionData
|
||||
} from '@/modules/gatekeeper/domain/billing'
|
||||
import {
|
||||
WorkspacePlanBillingIntervals,
|
||||
@@ -163,19 +163,13 @@ export const parseSubscriptionData = (
|
||||
// this should be a reconcile subscriptions, we keep an accurate state in the DB
|
||||
// on each change, we're reconciling that state to stripe
|
||||
export const reconcileWorkspaceSubscriptionFactory =
|
||||
({ stripe }: { stripe: Stripe }) =>
|
||||
async ({
|
||||
workspaceSubscription,
|
||||
applyProrotation
|
||||
}: {
|
||||
workspaceSubscription: WorkspaceSubscription
|
||||
applyProrotation: boolean
|
||||
}) => {
|
||||
({ stripe }: { stripe: Stripe }): ReconcileSubscriptionData =>
|
||||
async ({ subscriptionData, applyProrotation }) => {
|
||||
const existingSubscriptionState = await getSubscriptionDataFactory({ stripe })({
|
||||
subscriptionId: workspaceSubscription.subscriptionData.subscriptionId
|
||||
subscriptionId: subscriptionData.subscriptionId
|
||||
})
|
||||
const items: Stripe.SubscriptionUpdateParams.Item[] = []
|
||||
for (const product of workspaceSubscription.subscriptionData.products) {
|
||||
for (const product of subscriptionData.products) {
|
||||
const existingProduct = existingSubscriptionState.products.find(
|
||||
(p) => p.productId === product.productId
|
||||
)
|
||||
@@ -187,13 +181,16 @@ export const reconcileWorkspaceSubscriptionFactory =
|
||||
items.push({ quantity: product.quantity, price: product.priceId })
|
||||
items.push({ id: product.subscriptionItemId, deleted: true })
|
||||
} else {
|
||||
items.push({ quantity: product.quantity, id: product.subscriptionItemId })
|
||||
items.push({
|
||||
quantity: product.quantity,
|
||||
id: existingProduct.subscriptionItemId
|
||||
})
|
||||
}
|
||||
}
|
||||
// workspaceSubscription.subscriptionData.products.
|
||||
// const item = workspaceSubscription.subscriptionData.products.find(p => p.)
|
||||
await stripe.subscriptions.update(
|
||||
workspaceSubscription.subscriptionData.subscriptionId,
|
||||
{ items, proration_behavior: applyProrotation ? 'create_prorations' : 'none' }
|
||||
)
|
||||
await stripe.subscriptions.update(subscriptionData.subscriptionId, {
|
||||
items,
|
||||
proration_behavior: applyProrotation ? 'create_prorations' : 'none'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
WorkspacePlanBillingIntervals,
|
||||
WorkspacePricingPlans
|
||||
} from '@/modules/gatekeeper/domain/workspacePricing'
|
||||
import { OverrideProperties } from 'type-fest'
|
||||
import { z } from 'zod'
|
||||
|
||||
export type UnpaidWorkspacePlanStatuses = 'valid'
|
||||
@@ -109,6 +110,15 @@ export type WorkspaceSubscription = {
|
||||
billingInterval: WorkspacePlanBillingIntervals
|
||||
subscriptionData: SubscriptionData
|
||||
}
|
||||
const subscriptionProduct = z.object({
|
||||
productId: z.string(),
|
||||
subscriptionItemId: z.string(),
|
||||
priceId: z.string(),
|
||||
quantity: z.number()
|
||||
})
|
||||
|
||||
type SubscriptionProduct = z.infer<typeof subscriptionProduct>
|
||||
|
||||
export const subscriptionData = z.object({
|
||||
subscriptionId: z.string().min(1),
|
||||
customerId: z.string().min(1),
|
||||
@@ -123,15 +133,7 @@ export const subscriptionData = z.object({
|
||||
z.literal('unpaid'),
|
||||
z.literal('paused')
|
||||
]),
|
||||
products: z
|
||||
.object({
|
||||
// we're going to use the productId to match with our
|
||||
productId: z.string(),
|
||||
subscriptionItemId: z.string(),
|
||||
priceId: z.string(),
|
||||
quantity: z.number()
|
||||
})
|
||||
.array()
|
||||
products: subscriptionProduct.array()
|
||||
})
|
||||
|
||||
// this abstracts the stripe sub data
|
||||
@@ -158,7 +160,18 @@ export type GetWorkspacePlanPrice = (args: {
|
||||
billingInterval: WorkspacePlanBillingIntervals
|
||||
}) => string
|
||||
|
||||
export type ReconcileWorkspaceSubscription = (args: {
|
||||
workspaceSubscription: WorkspaceSubscription
|
||||
export type GetWorkspacePlanProductId = (args: {
|
||||
workspacePlan: WorkspacePricingPlans
|
||||
}) => string
|
||||
|
||||
export type SubscriptionDataInput = OverrideProperties<
|
||||
SubscriptionData,
|
||||
{
|
||||
products: OverrideProperties<SubscriptionProduct, { subscriptionItemId?: string }>[]
|
||||
}
|
||||
>
|
||||
|
||||
export type ReconcileSubscriptionData = (args: {
|
||||
subscriptionData: SubscriptionDataInput
|
||||
applyProrotation: boolean
|
||||
}) => Promise<void>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { reconcileWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/clients/stripe'
|
||||
import {
|
||||
getWorkspacePlanFactory,
|
||||
getWorkspaceSubscriptionFactory
|
||||
} from '@/modules/gatekeeper/repositories/billing'
|
||||
import { addWorkspaceSubscriptionSeatIfNeededFactory } from '@/modules/gatekeeper/services/subscriptions'
|
||||
import {
|
||||
getWorkspacePlanPrice,
|
||||
getWorkspacePlanProductId
|
||||
} from '@/modules/gatekeeper/stripe'
|
||||
import { getEventBus } from '@/modules/shared/services/eventBus'
|
||||
import { countWorkspaceRoleWithOptionalProjectRoleFactory } from '@/modules/workspaces/repositories/workspaces'
|
||||
import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events'
|
||||
import { Knex } from 'knex'
|
||||
import Stripe from 'stripe'
|
||||
|
||||
export const initializeEventListenersFactory =
|
||||
({ db, stripe }: { db: Knex; stripe: Stripe }) =>
|
||||
() => {
|
||||
const eventBus = getEventBus()
|
||||
const quitCbs = [
|
||||
eventBus.listen(WorkspaceEvents.RoleUpdated, async ({ payload }) => {
|
||||
const addWorkspaceSubscriptionSeatIfNeeded =
|
||||
addWorkspaceSubscriptionSeatIfNeededFactory({
|
||||
getWorkspacePlan: getWorkspacePlanFactory({ db }),
|
||||
getWorkspaceSubscription: getWorkspaceSubscriptionFactory({ db }),
|
||||
countWorkspaceRole: countWorkspaceRoleWithOptionalProjectRoleFactory({
|
||||
db
|
||||
}),
|
||||
getWorkspacePlanPrice,
|
||||
getWorkspacePlanProductId,
|
||||
reconcileSubscriptionData: reconcileWorkspaceSubscriptionFactory({ stripe })
|
||||
})
|
||||
|
||||
await addWorkspaceSubscriptionSeatIfNeeded(payload)
|
||||
})
|
||||
]
|
||||
|
||||
return () => quitCbs.forEach((quit) => quit())
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { getBillingRouter } from '@/modules/gatekeeper/rest/billing'
|
||||
import { registerOrUpdateScopeFactory } from '@/modules/shared/repositories/scopes'
|
||||
import { db } from '@/db/knex'
|
||||
import { gatekeeperScopes } from '@/modules/gatekeeper/scopes'
|
||||
import { initializeEventListenersFactory } from '@/modules/gatekeeper/events/eventListener'
|
||||
import { getStripeClient } from '@/modules/gatekeeper/stripe'
|
||||
|
||||
const { FF_GATEKEEPER_MODULE_ENABLED, FF_BILLING_INTEGRATION_ENABLED } =
|
||||
getFeatureFlags()
|
||||
@@ -15,6 +17,8 @@ const initScopes = async () => {
|
||||
await Promise.all(gatekeeperScopes.map((scope) => registerFunc({ scope })))
|
||||
}
|
||||
|
||||
let quitListeners: (() => void) | undefined = undefined
|
||||
|
||||
const gatekeeperModule: SpeckleModule = {
|
||||
async init(app, isInitial) {
|
||||
await initScopes()
|
||||
@@ -35,6 +39,11 @@ const gatekeeperModule: SpeckleModule = {
|
||||
if (FF_BILLING_INTEGRATION_ENABLED) {
|
||||
app.use(getBillingRouter())
|
||||
|
||||
quitListeners = initializeEventListenersFactory({
|
||||
db,
|
||||
stripe: getStripeClient()
|
||||
})()
|
||||
|
||||
const isLicenseValid = await validateModuleLicense({
|
||||
requiredModules: ['billing']
|
||||
})
|
||||
@@ -45,6 +54,9 @@ const gatekeeperModule: SpeckleModule = {
|
||||
// TODO: create a cron job, that removes unused seats from the subscription at the beginning of each workspace plan's billing cycle
|
||||
}
|
||||
}
|
||||
},
|
||||
async shutdown() {
|
||||
if (quitListeners) quitListeners()
|
||||
}
|
||||
}
|
||||
export = gatekeeperModule
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import {
|
||||
GetWorkspacePlan,
|
||||
GetWorkspacePlanPrice,
|
||||
GetWorkspacePlanProductId,
|
||||
GetWorkspaceSubscription,
|
||||
GetWorkspaceSubscriptionBySubscriptionId,
|
||||
PaidWorkspacePlanStatuses,
|
||||
ReconcileSubscriptionData,
|
||||
SubscriptionData,
|
||||
SubscriptionDataInput,
|
||||
UpsertPaidWorkspacePlan,
|
||||
UpsertWorkspaceSubscription
|
||||
} from '@/modules/gatekeeper/domain/billing'
|
||||
@@ -11,7 +16,9 @@ import {
|
||||
WorkspacePlanNotFoundError,
|
||||
WorkspaceSubscriptionNotFoundError
|
||||
} from '@/modules/gatekeeper/errors/billing'
|
||||
import { throwUncoveredError } from '@speckle/shared'
|
||||
import { CountWorkspaceRoleWithOptionalProjectRole } from '@/modules/workspaces/domain/operations'
|
||||
import { throwUncoveredError, WorkspaceRoles } from '@speckle/shared'
|
||||
import { cloneDeep, sum } from 'lodash'
|
||||
|
||||
export const handleSubscriptionUpdateFactory =
|
||||
({
|
||||
@@ -74,7 +81,92 @@ export const handleSubscriptionUpdateFactory =
|
||||
})
|
||||
// if there is a status in the sub, we recognize, we need to update our state
|
||||
await upsertWorkspaceSubscription({
|
||||
workspaceSubscription: { ...subscription, subscriptionData }
|
||||
workspaceSubscription: {
|
||||
...subscription,
|
||||
updatedAt: new Date(),
|
||||
subscriptionData
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const addWorkspaceSubscriptionSeatIfNeededFactory =
|
||||
({
|
||||
getWorkspacePlan,
|
||||
getWorkspaceSubscription,
|
||||
countWorkspaceRole,
|
||||
getWorkspacePlanProductId,
|
||||
getWorkspacePlanPrice,
|
||||
reconcileSubscriptionData
|
||||
}: {
|
||||
getWorkspacePlan: GetWorkspacePlan
|
||||
getWorkspaceSubscription: GetWorkspaceSubscription
|
||||
countWorkspaceRole: CountWorkspaceRoleWithOptionalProjectRole
|
||||
getWorkspacePlanProductId: GetWorkspacePlanProductId
|
||||
getWorkspacePlanPrice: GetWorkspacePlanPrice
|
||||
reconcileSubscriptionData: ReconcileSubscriptionData
|
||||
}) =>
|
||||
async ({ workspaceId, role }: { workspaceId: string; role: WorkspaceRoles }) => {
|
||||
const workspacePlan = await getWorkspacePlan({ workspaceId })
|
||||
if (!workspacePlan) throw new WorkspacePlanNotFoundError()
|
||||
const workspaceSubscription = await getWorkspaceSubscription({ workspaceId })
|
||||
if (!workspaceSubscription) throw new WorkspaceSubscriptionNotFoundError()
|
||||
|
||||
switch (workspacePlan.name) {
|
||||
case 'team':
|
||||
case 'pro':
|
||||
case 'business':
|
||||
break
|
||||
case 'unlimited':
|
||||
case 'academia':
|
||||
throw new WorkspacePlanMismatchError()
|
||||
default:
|
||||
throwUncoveredError(workspacePlan)
|
||||
}
|
||||
|
||||
let productId: string
|
||||
let priceId: string
|
||||
let roleCount: number
|
||||
switch (role) {
|
||||
case 'workspace:guest':
|
||||
roleCount = await countWorkspaceRole({ workspaceId, workspaceRole: role })
|
||||
productId = getWorkspacePlanProductId({ workspacePlan: 'guest' })
|
||||
priceId = getWorkspacePlanPrice({
|
||||
workspacePlan: 'guest',
|
||||
billingInterval: workspaceSubscription.billingInterval
|
||||
})
|
||||
break
|
||||
case 'workspace:admin':
|
||||
case 'workspace:member':
|
||||
roleCount = sum(
|
||||
await Promise.all([
|
||||
countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:admin' }),
|
||||
countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:member' })
|
||||
])
|
||||
)
|
||||
productId = getWorkspacePlanProductId({ workspacePlan: workspacePlan.name })
|
||||
priceId = getWorkspacePlanPrice({
|
||||
workspacePlan: workspacePlan.name,
|
||||
billingInterval: workspaceSubscription.billingInterval
|
||||
})
|
||||
break
|
||||
default:
|
||||
throwUncoveredError(role)
|
||||
}
|
||||
|
||||
const subscriptionData: SubscriptionDataInput = cloneDeep(
|
||||
workspaceSubscription.subscriptionData
|
||||
)
|
||||
|
||||
const currentPlanProduct = subscriptionData.products.find(
|
||||
(product) => product.productId === productId
|
||||
)
|
||||
if (!currentPlanProduct) {
|
||||
subscriptionData.products.push({ productId, priceId, quantity: roleCount })
|
||||
} else {
|
||||
// if there is enough seats, we do not have to do anything
|
||||
if (currentPlanProduct.quantity >= roleCount) return
|
||||
currentPlanProduct.quantity = roleCount
|
||||
}
|
||||
await reconcileSubscriptionData({ subscriptionData, applyProrotation: true })
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { GetWorkspacePlanPrice } from '@/modules/gatekeeper/domain/billing'
|
||||
import {
|
||||
GetWorkspacePlanPrice,
|
||||
GetWorkspacePlanProductId
|
||||
} from '@/modules/gatekeeper/domain/billing'
|
||||
import {
|
||||
WorkspacePlanBillingIntervals,
|
||||
WorkspacePricingPlans
|
||||
@@ -43,3 +46,7 @@ export const getWorkspacePlanPrice: GetWorkspacePlanPrice = ({
|
||||
workspacePlan,
|
||||
billingInterval
|
||||
}) => workspacePlanPrices()[workspacePlan][billingInterval]
|
||||
|
||||
export const getWorkspacePlanProductId: GetWorkspacePlanProductId = ({
|
||||
workspacePlan
|
||||
}) => workspacePlanPrices()[workspacePlan].productId
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
SubscriptionData,
|
||||
SubscriptionDataInput,
|
||||
WorkspacePlan,
|
||||
WorkspaceSubscription
|
||||
} from '@/modules/gatekeeper/domain/billing'
|
||||
@@ -8,11 +9,15 @@ import {
|
||||
WorkspacePlanNotFoundError,
|
||||
WorkspaceSubscriptionNotFoundError
|
||||
} from '@/modules/gatekeeper/errors/billing'
|
||||
import { handleSubscriptionUpdateFactory } from '@/modules/gatekeeper/services/subscriptions'
|
||||
import {
|
||||
addWorkspaceSubscriptionSeatIfNeededFactory,
|
||||
handleSubscriptionUpdateFactory
|
||||
} from '@/modules/gatekeeper/services/subscriptions'
|
||||
import { expectToThrow } from '@/test/assertionHelper'
|
||||
import { throwUncoveredError } from '@speckle/shared'
|
||||
import { expect } from 'chai'
|
||||
import cryptoRandomString from 'crypto-random-string'
|
||||
import { merge } from 'lodash'
|
||||
import { assign } from 'lodash'
|
||||
|
||||
const createTestSubscriptionData = (
|
||||
overrides: Partial<SubscriptionData> = {}
|
||||
@@ -31,7 +36,21 @@ const createTestSubscriptionData = (
|
||||
status: 'active',
|
||||
subscriptionId: cryptoRandomString({ length: 10 })
|
||||
}
|
||||
return merge(defaultValues, overrides)
|
||||
return assign(defaultValues, overrides)
|
||||
}
|
||||
|
||||
const createTestWorkspaceSubscription = (
|
||||
overrides: Partial<WorkspaceSubscription> = {}
|
||||
): WorkspaceSubscription => {
|
||||
const defaultValues: WorkspaceSubscription = {
|
||||
billingInterval: 'monthly',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
currentBillingCycleEnd: new Date(),
|
||||
subscriptionData: createTestSubscriptionData(),
|
||||
workspaceId: cryptoRandomString({ length: 10 })
|
||||
}
|
||||
return assign(defaultValues, overrides)
|
||||
}
|
||||
|
||||
describe('subscriptions @gatekeeper', () => {
|
||||
@@ -58,14 +77,8 @@ describe('subscriptions @gatekeeper', () => {
|
||||
const subscriptionData = createTestSubscriptionData()
|
||||
const err = await expectToThrow(async () => {
|
||||
await handleSubscriptionUpdateFactory({
|
||||
getWorkspaceSubscriptionBySubscriptionId: async () => ({
|
||||
subscriptionData,
|
||||
billingInterval: 'monthly',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
currentBillingCycleEnd: new Date(),
|
||||
workspaceId: cryptoRandomString({ length: 10 })
|
||||
}),
|
||||
getWorkspaceSubscriptionBySubscriptionId: async () =>
|
||||
createTestWorkspaceSubscription({ subscriptionData }),
|
||||
getWorkspacePlan: async () => null,
|
||||
upsertWorkspaceSubscription: async () => {
|
||||
expect.fail()
|
||||
@@ -83,14 +96,11 @@ describe('subscriptions @gatekeeper', () => {
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const err = await expectToThrow(async () => {
|
||||
await handleSubscriptionUpdateFactory({
|
||||
getWorkspaceSubscriptionBySubscriptionId: async () => ({
|
||||
subscriptionData,
|
||||
billingInterval: 'monthly',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
currentBillingCycleEnd: new Date(),
|
||||
workspaceId
|
||||
}),
|
||||
getWorkspaceSubscriptionBySubscriptionId: async () =>
|
||||
createTestWorkspaceSubscription({
|
||||
subscriptionData,
|
||||
workspaceId
|
||||
}),
|
||||
getWorkspacePlan: async () => ({ name, workspaceId, status: 'valid' }),
|
||||
upsertWorkspaceSubscription: async () => {
|
||||
expect.fail()
|
||||
@@ -109,14 +119,10 @@ describe('subscriptions @gatekeeper', () => {
|
||||
cancelAt: new Date(2099, 12, 31)
|
||||
})
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const workspaceSubscription = {
|
||||
const workspaceSubscription = createTestWorkspaceSubscription({
|
||||
subscriptionData,
|
||||
billingInterval: 'monthly' as const,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
currentBillingCycleEnd: new Date(),
|
||||
workspaceId
|
||||
}
|
||||
})
|
||||
|
||||
let updatedSubscription: WorkspaceSubscription | undefined = undefined
|
||||
let updatedPlan: WorkspacePlan | undefined = undefined
|
||||
@@ -170,14 +176,11 @@ describe('subscriptions @gatekeeper', () => {
|
||||
status: 'past_due'
|
||||
})
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const workspaceSubscription = {
|
||||
|
||||
const workspaceSubscription = createTestWorkspaceSubscription({
|
||||
subscriptionData,
|
||||
billingInterval: 'monthly' as const,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
currentBillingCycleEnd: new Date(),
|
||||
workspaceId
|
||||
}
|
||||
})
|
||||
|
||||
let updatedSubscription: WorkspaceSubscription | undefined = undefined
|
||||
let updatedPlan: WorkspacePlan | undefined = undefined
|
||||
@@ -233,14 +236,11 @@ describe('subscriptions @gatekeeper', () => {
|
||||
status
|
||||
})
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const workspaceSubscription = {
|
||||
|
||||
const workspaceSubscription = createTestWorkspaceSubscription({
|
||||
subscriptionData,
|
||||
billingInterval: 'monthly' as const,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
currentBillingCycleEnd: new Date(),
|
||||
workspaceId
|
||||
}
|
||||
})
|
||||
|
||||
await handleSubscriptionUpdateFactory({
|
||||
getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription,
|
||||
@@ -259,4 +259,365 @@ describe('subscriptions @gatekeeper', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('addWorkspaceSubscriptionSeatIfNeededFactory returns a function, that', () => {
|
||||
it('throws if the workspacePlan is not found', async () => {
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const addWorkspaceSubscriptionSeatIfNeeded =
|
||||
addWorkspaceSubscriptionSeatIfNeededFactory({
|
||||
getWorkspacePlan: async () => null,
|
||||
getWorkspaceSubscription: async () => {
|
||||
expect.fail()
|
||||
},
|
||||
countWorkspaceRole: async () => {
|
||||
expect.fail()
|
||||
},
|
||||
getWorkspacePlanPrice: () => {
|
||||
expect.fail()
|
||||
},
|
||||
getWorkspacePlanProductId: () => {
|
||||
expect.fail()
|
||||
},
|
||||
reconcileSubscriptionData: async () => {
|
||||
expect.fail()
|
||||
}
|
||||
})
|
||||
const err = await expectToThrow(async () => {
|
||||
await addWorkspaceSubscriptionSeatIfNeeded({
|
||||
workspaceId,
|
||||
role: 'workspace:admin'
|
||||
})
|
||||
})
|
||||
expect(err.message).to.equal(new WorkspacePlanNotFoundError().message)
|
||||
})
|
||||
it('throws if the workspaceSubscription is not found', async () => {
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const addWorkspaceSubscriptionSeatIfNeeded =
|
||||
addWorkspaceSubscriptionSeatIfNeededFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
name: 'unlimited',
|
||||
workspaceId,
|
||||
status: 'valid'
|
||||
}),
|
||||
getWorkspaceSubscription: async () => null,
|
||||
countWorkspaceRole: async () => {
|
||||
expect.fail()
|
||||
},
|
||||
getWorkspacePlanPrice: () => {
|
||||
expect.fail()
|
||||
},
|
||||
getWorkspacePlanProductId: () => {
|
||||
expect.fail()
|
||||
},
|
||||
reconcileSubscriptionData: async () => {
|
||||
expect.fail()
|
||||
}
|
||||
})
|
||||
const err = await expectToThrow(async () => {
|
||||
await addWorkspaceSubscriptionSeatIfNeeded({
|
||||
workspaceId,
|
||||
role: 'workspace:admin'
|
||||
})
|
||||
})
|
||||
expect(err.message).to.equal(new WorkspaceSubscriptionNotFoundError().message)
|
||||
})
|
||||
it('throws if a non paid plan, has a subscription', async () => {
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const subscriptionData = createTestSubscriptionData({ products: [] })
|
||||
const workspaceSubscription = createTestWorkspaceSubscription({
|
||||
workspaceId,
|
||||
subscriptionData
|
||||
})
|
||||
const addWorkspaceSubscriptionSeatIfNeeded =
|
||||
addWorkspaceSubscriptionSeatIfNeededFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
name: 'unlimited',
|
||||
workspaceId,
|
||||
status: 'valid'
|
||||
}),
|
||||
getWorkspaceSubscription: async () => workspaceSubscription,
|
||||
countWorkspaceRole: async () => {
|
||||
expect.fail()
|
||||
},
|
||||
getWorkspacePlanPrice: () => {
|
||||
expect.fail()
|
||||
},
|
||||
getWorkspacePlanProductId: () => {
|
||||
expect.fail()
|
||||
},
|
||||
reconcileSubscriptionData: async () => {
|
||||
expect.fail()
|
||||
}
|
||||
})
|
||||
const err = await expectToThrow(async () => {
|
||||
await addWorkspaceSubscriptionSeatIfNeeded({
|
||||
workspaceId,
|
||||
role: 'workspace:admin'
|
||||
})
|
||||
})
|
||||
expect(err.message).to.equal(new WorkspacePlanMismatchError().message)
|
||||
})
|
||||
it('uses the guest count, guest product and price id if the new role is workspace:guest', async () => {
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const subscriptionData = createTestSubscriptionData({ products: [] })
|
||||
const workspaceSubscription = createTestWorkspaceSubscription({
|
||||
workspaceId,
|
||||
subscriptionData
|
||||
})
|
||||
const workspacePlan: WorkspacePlan = {
|
||||
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,
|
||||
countWorkspaceRole: async ({ workspaceRole }) => {
|
||||
switch (workspaceRole) {
|
||||
case 'workspace:admin':
|
||||
case 'workspace:member':
|
||||
expect.fail()
|
||||
case 'workspace:guest':
|
||||
return roleCount
|
||||
}
|
||||
},
|
||||
getWorkspacePlanPrice: ({ workspacePlan, billingInterval }) => {
|
||||
if (billingInterval !== workspaceSubscription.billingInterval) expect.fail()
|
||||
switch (workspacePlan) {
|
||||
case 'business':
|
||||
case 'team':
|
||||
case 'pro':
|
||||
expect.fail()
|
||||
case 'guest':
|
||||
return priceId
|
||||
default:
|
||||
throwUncoveredError(workspacePlan)
|
||||
}
|
||||
},
|
||||
getWorkspacePlanProductId: (args) => {
|
||||
if (args.workspacePlan !== 'guest') expect.fail()
|
||||
return productId
|
||||
},
|
||||
reconcileSubscriptionData: async ({ applyProrotation, subscriptionData }) => {
|
||||
if (!applyProrotation) expect.fail()
|
||||
reconciledSubscriptionData = subscriptionData
|
||||
}
|
||||
})
|
||||
await addWorkspaceSubscriptionSeatIfNeeded({
|
||||
workspaceId,
|
||||
role: 'workspace:guest'
|
||||
})
|
||||
expect(reconciledSubscriptionData!.products).deep.equalInAnyOrder([
|
||||
{ productId, priceId, quantity: roleCount }
|
||||
])
|
||||
})
|
||||
;(['workspace:member', 'workspace:admin'] as const).forEach((role) =>
|
||||
it(`uses the admin + member count, workspacePlan product and price id if the new role is ${role}`, async () => {
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const subscriptionData = createTestSubscriptionData({ products: [] })
|
||||
const workspaceSubscription = createTestWorkspaceSubscription({
|
||||
workspaceId,
|
||||
subscriptionData
|
||||
})
|
||||
const workspacePlan: WorkspacePlan = {
|
||||
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,
|
||||
countWorkspaceRole: async ({ workspaceRole }) => {
|
||||
switch (workspaceRole) {
|
||||
case 'workspace:admin':
|
||||
case 'workspace:member':
|
||||
return roleCount
|
||||
case 'workspace:guest':
|
||||
expect.fail()
|
||||
}
|
||||
},
|
||||
getWorkspacePlanPrice: ({ workspacePlan, billingInterval }) => {
|
||||
if (billingInterval !== workspaceSubscription.billingInterval)
|
||||
expect.fail()
|
||||
switch (workspacePlan) {
|
||||
case 'business':
|
||||
case 'pro':
|
||||
case 'guest':
|
||||
expect.fail()
|
||||
case 'team':
|
||||
return priceId
|
||||
default:
|
||||
throwUncoveredError(workspacePlan)
|
||||
}
|
||||
},
|
||||
getWorkspacePlanProductId: (args) => {
|
||||
if (args.workspacePlan !== workspacePlan.name) expect.fail()
|
||||
return productId
|
||||
},
|
||||
reconcileSubscriptionData: async ({
|
||||
applyProrotation,
|
||||
subscriptionData
|
||||
}) => {
|
||||
if (!applyProrotation) expect.fail()
|
||||
reconciledSubscriptionData = subscriptionData
|
||||
}
|
||||
})
|
||||
await addWorkspaceSubscriptionSeatIfNeeded({
|
||||
workspaceId,
|
||||
role
|
||||
})
|
||||
expect(reconciledSubscriptionData!.products).deep.equalInAnyOrder([
|
||||
{ productId, priceId, quantity: 2 * roleCount }
|
||||
])
|
||||
})
|
||||
)
|
||||
it('updates the sub existing product quantity if the one matching the new role, does not have enough quantities', async () => {
|
||||
const workspaceId = 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: 4,
|
||||
subscriptionItemId
|
||||
}
|
||||
]
|
||||
})
|
||||
const workspaceSubscription = createTestWorkspaceSubscription({
|
||||
workspaceId,
|
||||
subscriptionData
|
||||
})
|
||||
const workspacePlan: WorkspacePlan = {
|
||||
name: 'team',
|
||||
workspaceId,
|
||||
status: 'valid'
|
||||
}
|
||||
const roleCount = 10
|
||||
|
||||
let reconciledSubscriptionData: SubscriptionDataInput | undefined = undefined
|
||||
const addWorkspaceSubscriptionSeatIfNeeded =
|
||||
addWorkspaceSubscriptionSeatIfNeededFactory({
|
||||
getWorkspacePlan: async () => workspacePlan,
|
||||
getWorkspaceSubscription: async () => workspaceSubscription,
|
||||
countWorkspaceRole: async ({ workspaceRole }) => {
|
||||
switch (workspaceRole) {
|
||||
case 'workspace:admin':
|
||||
case 'workspace:member':
|
||||
return roleCount
|
||||
case 'workspace:guest':
|
||||
expect.fail()
|
||||
}
|
||||
},
|
||||
getWorkspacePlanPrice: ({ workspacePlan, billingInterval }) => {
|
||||
if (billingInterval !== workspaceSubscription.billingInterval) expect.fail()
|
||||
switch (workspacePlan) {
|
||||
case 'business':
|
||||
case 'pro':
|
||||
case 'guest':
|
||||
expect.fail()
|
||||
case 'team':
|
||||
return priceId
|
||||
default:
|
||||
throwUncoveredError(workspacePlan)
|
||||
}
|
||||
},
|
||||
getWorkspacePlanProductId: (args) => {
|
||||
if (args.workspacePlan !== workspacePlan.name) expect.fail()
|
||||
return productId
|
||||
},
|
||||
reconcileSubscriptionData: async ({ applyProrotation, subscriptionData }) => {
|
||||
if (!applyProrotation) expect.fail()
|
||||
reconciledSubscriptionData = subscriptionData
|
||||
}
|
||||
})
|
||||
await addWorkspaceSubscriptionSeatIfNeeded({
|
||||
workspaceId,
|
||||
role: 'workspace:member'
|
||||
})
|
||||
expect(reconciledSubscriptionData!.products).deep.equalInAnyOrder([
|
||||
{ productId, priceId, quantity: 2 * 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 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 = {
|
||||
name: 'team',
|
||||
workspaceId,
|
||||
status: 'valid'
|
||||
}
|
||||
const roleCount = 1
|
||||
|
||||
const addWorkspaceSubscriptionSeatIfNeeded =
|
||||
addWorkspaceSubscriptionSeatIfNeededFactory({
|
||||
getWorkspacePlan: async () => workspacePlan,
|
||||
getWorkspaceSubscription: async () => workspaceSubscription,
|
||||
countWorkspaceRole: async ({ workspaceRole }) => {
|
||||
switch (workspaceRole) {
|
||||
case 'workspace:admin':
|
||||
case 'workspace:member':
|
||||
return roleCount
|
||||
case 'workspace:guest':
|
||||
expect.fail()
|
||||
}
|
||||
},
|
||||
getWorkspacePlanPrice: ({ workspacePlan, billingInterval }) => {
|
||||
if (billingInterval !== workspaceSubscription.billingInterval) expect.fail()
|
||||
switch (workspacePlan) {
|
||||
case 'business':
|
||||
case 'pro':
|
||||
case 'guest':
|
||||
expect.fail()
|
||||
case 'team':
|
||||
return priceId
|
||||
default:
|
||||
throwUncoveredError(workspacePlan)
|
||||
}
|
||||
},
|
||||
getWorkspacePlanProductId: (args) => {
|
||||
if (args.workspacePlan !== workspacePlan.name) expect.fail()
|
||||
return productId
|
||||
},
|
||||
reconcileSubscriptionData: async () => {
|
||||
expect.fail()
|
||||
}
|
||||
})
|
||||
await addWorkspaceSubscriptionSeatIfNeeded({
|
||||
workspaceId,
|
||||
role: 'workspace:member'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -97,7 +97,8 @@
|
||||
"Encryptor",
|
||||
"Insertable",
|
||||
"mjml",
|
||||
"OIDC"
|
||||
"OIDC",
|
||||
"Prorotation"
|
||||
],
|
||||
"tailwindCSS.experimental.configFile": {
|
||||
"packages/frontend-2/tailwind.config.mjs": "packages/frontend-2/**"
|
||||
|
||||
Reference in New Issue
Block a user