feat(gatekeeper): upsize subscription on workspace role change

This commit is contained in:
Gergő Jedlicska
2024-10-24 10:03:08 +02:00
parent f905c8f428
commit 81b923cf67
8 changed files with 592 additions and 69 deletions
@@ -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 })
}
+8 -1
View File
@@ -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'
})
})
})
})
+2 -1
View File
@@ -97,7 +97,8 @@
"Encryptor",
"Insertable",
"mjml",
"OIDC"
"OIDC",
"Prorotation"
],
"tailwindCSS.experimental.configFile": {
"packages/frontend-2/tailwind.config.mjs": "packages/frontend-2/**"