Files
speckle-server/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts
T
Gergő Jedlicska 6982023dca feat(gatekeeper): add per workspace feature flags (#5303)
* 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
2025-08-26 10:23:02 +01:00

577 lines
21 KiB
TypeScript

import {
CheckoutSessionNotFoundError,
WorkspaceAlreadyPaidError,
WorkspaceCheckoutSessionInProgressError
} from '@/modules/gatekeeper/errors/billing'
import { completeCheckoutSessionFactory } from '@/modules/gatekeeper/services/checkout'
import { expectToThrow } from '@/test/assertionHelper'
import { expect } from 'chai'
import cryptoRandomString from 'crypto-random-string'
import type {
CheckoutSession,
SubscriptionData,
WorkspaceSubscription
} from '@/modules/gatekeeper/domain/billing'
import { omit } from 'lodash-es'
import type { PaidWorkspacePlan, WorkspacePlanBillingIntervals } from '@speckle/shared'
import { PaidWorkspacePlans } from '@speckle/shared'
import { startCheckoutSessionFactory } from '@/modules/gatekeeper/services/checkout/startCheckoutSession'
import { NotFoundError } from '@/modules/shared/errors'
import { buildTestWorkspacePlan } from '@/modules/gatekeeper/tests/helpers/workspacePlan'
describe('checkout @gatekeeper', () => {
describe('completeCheckoutSessionFactory creates a function, that', () => {
it('throws a CheckoutSessionNotFound if the checkoutSession is null', async () => {
const sessionId = cryptoRandomString({ length: 10 })
const subscriptionId = cryptoRandomString({ length: 10 })
const err = await expectToThrow(async () => {
await completeCheckoutSessionFactory({
getCheckoutSession: async () => null,
updateCheckoutSessionStatus: async () => {
expect.fail()
},
upsertPaidWorkspacePlan: async () => {
expect.fail()
},
getWorkspacePlan: async () => null,
getWorkspaceSubscription: async () => null,
getSubscriptionData: async () => {
expect.fail()
},
emitEvent: async () => {
expect.fail()
},
upsertWorkspaceSubscription: async () => {
expect.fail()
}
})({ sessionId, subscriptionId })
expect(err.message).to.equal(new CheckoutSessionNotFoundError().message)
})
})
it('throws for already paid checkout sessions', async () => {
const sessionId = cryptoRandomString({ length: 10 })
const subscriptionId = cryptoRandomString({ length: 10 })
const userId = cryptoRandomString({ length: 10 })
const err = await expectToThrow(async () => {
await completeCheckoutSessionFactory({
getCheckoutSession: async () => ({
billingInterval: 'monthly',
id: sessionId,
userId,
paymentStatus: 'paid',
url: 'https://example.com',
workspaceId: cryptoRandomString({ length: 10 }),
workspacePlan: PaidWorkspacePlans.Team,
currency: 'usd',
createdAt: new Date(),
updatedAt: new Date()
}),
updateCheckoutSessionStatus: async () => {
expect.fail()
},
upsertPaidWorkspacePlan: async () => {
expect.fail()
},
getWorkspacePlan: async () => null,
getWorkspaceSubscription: async () => null,
getSubscriptionData: async () => {
expect.fail()
},
emitEvent: async () => {
expect.fail()
},
upsertWorkspaceSubscription: async () => {
expect.fail()
}
})({ sessionId, subscriptionId })
expect(err.message).to.equal(new WorkspaceAlreadyPaidError().message)
})
}),
(['monthly', 'yearly'] as const).forEach((billingInterval) => {
it(`sets the billingCycleEnd end for ${billingInterval} based on the checkoutSession.billingInterval`, async () => {
const sessionId = cryptoRandomString({ length: 10 })
const subscriptionId = cryptoRandomString({ length: 10 })
const workspaceId = cryptoRandomString({ length: 10 })
const userId = cryptoRandomString({ length: 10 })
const storedCheckoutSession: CheckoutSession = {
billingInterval,
id: sessionId,
userId,
paymentStatus: 'unpaid',
url: 'https://example.com',
workspaceId,
workspacePlan: PaidWorkspacePlans.Team,
currency: 'usd',
createdAt: new Date(),
updatedAt: new Date()
}
let storedWorkspacePlan: PaidWorkspacePlan | undefined = undefined
const subscriptionData: SubscriptionData = {
customerId: cryptoRandomString({ length: 10 }),
subscriptionId: cryptoRandomString({ length: 10 }),
products: [
{
priceId: cryptoRandomString({ length: 10 }),
productId: cryptoRandomString({ length: 10 }),
quantity: 10,
subscriptionItemId: cryptoRandomString({ length: 10 })
}
],
status: 'active',
cancelAt: null,
currentPeriodEnd: new Date()
}
let storedWorkspaceSubscriptionData: WorkspaceSubscription | undefined =
undefined
let emittedEventName: string | undefined = undefined
let emittedEventPayload: unknown
await completeCheckoutSessionFactory({
getCheckoutSession: async () => storedCheckoutSession,
updateCheckoutSessionStatus: async ({ paymentStatus }) => {
storedCheckoutSession.paymentStatus = paymentStatus
},
upsertPaidWorkspacePlan: async ({ workspacePlan }) => {
storedWorkspacePlan = workspacePlan
},
getWorkspacePlan: async () =>
buildTestWorkspacePlan({ workspaceId, name: 'free' }),
getWorkspaceSubscription: async () => null,
getSubscriptionData: async () => subscriptionData,
upsertWorkspaceSubscription: async ({ workspaceSubscription }) => {
storedWorkspaceSubscriptionData = workspaceSubscription
},
emitEvent: async ({ eventName, payload }) => {
emittedEventName = eventName
emittedEventPayload = payload
}
})({ sessionId, subscriptionId })
expect(storedCheckoutSession.paymentStatus).to.equal('paid')
expect(omit(storedWorkspacePlan, 'createdAt', 'updatedAt')).to.deep.equal({
workspaceId,
name: storedCheckoutSession.workspacePlan,
status: 'valid',
featureFlags: 0
})
expect(emittedEventName).to.equal('gatekeeper.workspace-subscription-updated')
expect(emittedEventPayload).to.nested.include({
'workspacePlan.workspaceId': workspaceId,
'workspacePlan.status': 'valid',
'workspacePlan.name': storedCheckoutSession.workspacePlan,
'previousWorkspacePlan.name': 'free',
'previousWorkspacePlan.status': 'valid',
'previousWorkspacePlan.workspaceId': workspaceId
})
expect(storedWorkspaceSubscriptionData!.billingInterval).to.equal(
storedCheckoutSession.billingInterval
)
expect(storedWorkspaceSubscriptionData!.subscriptionData).to.equal(
subscriptionData
)
// THIS CHECK IS BROKEN, IT ROLLS DAYS OVER, SO SOMETIMES IT SKIPS A MONTH
// THE SOLUTION IS TO GET THE NEXT DATE FROM STRIPE ANYHOW...
// let billingCycleEndsIn: number
// const expectedCycleLength = 1
// switch (billingInterval) {
// case 'monthly':
// billingCycleEndsIn =
// storedWorkspaceSubscriptionData!.currentBillingCycleEnd.getMonth() -
// new Date().getMonth()
// break
// case 'yearly':
// billingCycleEndsIn =
// storedWorkspaceSubscriptionData!.currentBillingCycleEnd.getFullYear() -
// new Date().getFullYear()
// break
// }
// expect(billingCycleEndsIn).to.be.equal(expectedCycleLength)
})
})
})
describe('startCheckoutSessionFactory creates a function, that', () => {
it('does not allow checkout if workspace plan does not exists', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const userId = cryptoRandomString({ length: 10 })
const err = await expectToThrow(() =>
startCheckoutSessionFactory({
getWorkspacePlan: async () => null,
getWorkspaceCheckoutSession: () => {
expect.fail()
},
countSeatsByTypeInWorkspace: () => {
expect.fail()
},
createCheckoutSession: () => {
expect.fail()
},
saveCheckoutSession: () => {
expect.fail()
},
deleteCheckoutSession: () => {
expect.fail()
}
})({
workspaceId,
userId,
billingInterval: 'monthly',
workspacePlan: 'pro',
workspaceSlug: cryptoRandomString({ length: 10 }),
isCreateFlow: false,
currency: 'usd'
})
)
expect(err.name).to.be.equal(new NotFoundError().name)
})
it('does not allow checkout for paid workspace plans, that is in a valid state', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const userId = cryptoRandomString({ length: 10 })
const err = await expectToThrow(() =>
startCheckoutSessionFactory({
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
name: 'team',
status: 'valid',
workspaceId
}),
getWorkspaceCheckoutSession: () => {
expect.fail()
},
countSeatsByTypeInWorkspace: () => {
expect.fail()
},
createCheckoutSession: () => {
expect.fail()
},
saveCheckoutSession: () => {
expect.fail()
},
deleteCheckoutSession: () => {
expect.fail()
}
})({
workspaceId,
userId,
billingInterval: 'monthly',
workspacePlan: 'pro',
workspaceSlug: cryptoRandomString({ length: 10 }),
isCreateFlow: false,
currency: 'usd'
})
)
expect(err.name).to.be.equal(new WorkspaceAlreadyPaidError().name)
})
it('does not allow checkout for workspace plans, that is in a paymentFailed state', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const userId = cryptoRandomString({ length: 10 })
const err = await expectToThrow(() =>
startCheckoutSessionFactory({
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
name: 'team',
status: 'paymentFailed',
workspaceId
}),
getWorkspaceCheckoutSession: () => {
expect.fail()
},
countSeatsByTypeInWorkspace: () => {
expect.fail()
},
createCheckoutSession: () => {
expect.fail()
},
deleteCheckoutSession: () => {
expect.fail()
},
saveCheckoutSession: () => {
expect.fail()
}
})({
workspaceId,
userId,
billingInterval: 'monthly',
workspacePlan: 'pro',
workspaceSlug: cryptoRandomString({ length: 10 }),
isCreateFlow: false,
currency: 'usd'
})
)
expect(err.message).to.be.equal(new WorkspaceAlreadyPaidError().message)
})
it('does not allow checkout for a workspace, that already has a checkout session', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const userId = cryptoRandomString({ length: 10 })
const err = await expectToThrow(() =>
startCheckoutSessionFactory({
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
name: 'free',
status: 'valid',
workspaceId
}),
getWorkspaceCheckoutSession: async () => ({
billingInterval: 'monthly',
id: cryptoRandomString({ length: 10 }),
paymentStatus: 'unpaid',
url: '',
workspaceId,
userId,
workspacePlan: PaidWorkspacePlans.Team,
currency: 'usd',
createdAt: new Date(),
updatedAt: new Date()
}),
countSeatsByTypeInWorkspace: () => {
expect.fail()
},
createCheckoutSession: () => {
expect.fail()
},
deleteCheckoutSession: () => {
expect.fail()
},
saveCheckoutSession: () => {
expect.fail()
}
})({
userId,
workspaceId,
billingInterval: 'monthly',
workspacePlan: 'team',
workspaceSlug: cryptoRandomString({ length: 10 }),
isCreateFlow: false,
currency: 'usd'
})
)
expect(err.message).to.be.equal(
new WorkspaceCheckoutSessionInProgressError().message
)
})
it('creates and stores a checkout for FREE workspaces', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const userId = cryptoRandomString({ length: 10 })
const workspacePlan: PaidWorkspacePlans = 'pro'
const billingInterval: WorkspacePlanBillingIntervals = 'monthly'
const checkoutSession: CheckoutSession = {
id: cryptoRandomString({ length: 10 }),
workspaceId,
userId,
workspacePlan,
url: 'https://example.com',
billingInterval,
paymentStatus: 'unpaid',
currency: 'usd',
createdAt: new Date(),
updatedAt: new Date()
}
let storedCheckoutSession: CheckoutSession | undefined = undefined
const createdCheckoutSession = await startCheckoutSessionFactory({
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
workspaceId,
name: 'free',
status: 'valid'
}),
getWorkspaceCheckoutSession: async () => null,
countSeatsByTypeInWorkspace: async () => 1,
deleteCheckoutSession: () => {
expect.fail()
},
createCheckoutSession: async () => checkoutSession,
saveCheckoutSession: async ({ checkoutSession }) => {
storedCheckoutSession = checkoutSession
}
})({
workspaceId,
userId,
billingInterval,
workspacePlan,
workspaceSlug: cryptoRandomString({ length: 10 }),
isCreateFlow: false,
currency: 'usd'
})
expect(checkoutSession).deep.equal(storedCheckoutSession)
expect(checkoutSession).deep.equal(createdCheckoutSession)
})
it('creates and stores a checkout for FREE workspaces even if it has an old unpaid checkout session', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const userId = cryptoRandomString({ length: 10 })
const workspacePlan: PaidWorkspacePlans = 'team'
const billingInterval: WorkspacePlanBillingIntervals = 'monthly'
const checkoutSession: CheckoutSession = {
id: cryptoRandomString({ length: 10 }),
workspaceId,
userId,
workspacePlan,
url: 'https://example.com',
billingInterval,
paymentStatus: 'unpaid',
currency: 'usd',
createdAt: new Date(),
updatedAt: new Date()
}
let existingCheckoutSession: CheckoutSession | undefined = {
billingInterval,
id: cryptoRandomString({ length: 10 }),
createdAt: new Date(1990, 1, 12),
updatedAt: new Date(1990, 1, 12),
paymentStatus: 'unpaid',
currency: 'usd',
url: 'https://example.com',
workspaceId,
userId,
workspacePlan
}
let storedCheckoutSession: CheckoutSession | undefined = undefined
const createdCheckoutSession = await startCheckoutSessionFactory({
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
workspaceId,
name: 'free',
status: 'valid'
}),
getWorkspaceCheckoutSession: async () => existingCheckoutSession!,
countSeatsByTypeInWorkspace: async () => 1,
deleteCheckoutSession: async () => {
existingCheckoutSession = undefined
},
createCheckoutSession: async () => checkoutSession,
saveCheckoutSession: async ({ checkoutSession }) => {
storedCheckoutSession = checkoutSession
}
})({
workspaceId,
userId,
billingInterval,
workspacePlan,
workspaceSlug: cryptoRandomString({ length: 10 }),
isCreateFlow: false,
currency: 'usd'
})
expect(existingCheckoutSession).to.be.undefined
expect(checkoutSession).deep.equal(storedCheckoutSession)
expect(checkoutSession).deep.equal(createdCheckoutSession)
})
it('does not allow checkout for FREE workspaces if there is a paid checkout session', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const userId = cryptoRandomString({ length: 10 })
const workspacePlan: PaidWorkspacePlans = 'pro'
const billingInterval: WorkspacePlanBillingIntervals = 'monthly'
let existingCheckoutSession: CheckoutSession | undefined = {
billingInterval,
id: cryptoRandomString({ length: 10 }),
createdAt: new Date(1990, 1, 12),
updatedAt: new Date(1990, 1, 12),
paymentStatus: 'paid',
url: 'https://example.com',
currency: 'usd',
workspaceId,
userId,
workspacePlan
}
const err = await expectToThrow(async () => {
await startCheckoutSessionFactory({
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
workspaceId,
name: 'free',
status: 'valid'
}),
getWorkspaceCheckoutSession: async () => existingCheckoutSession!,
countSeatsByTypeInWorkspace: async () => 1,
deleteCheckoutSession: async () => {
existingCheckoutSession = undefined
},
createCheckoutSession: async () => {
expect.fail()
},
saveCheckoutSession: async () => {}
})({
userId,
workspaceId,
billingInterval,
workspacePlan,
workspaceSlug: cryptoRandomString({ length: 10 }),
isCreateFlow: false,
currency: 'usd'
})
})
expect(err.message).to.equal(new WorkspaceAlreadyPaidError().message)
})
it('creates and stores a checkout for CANCELED workspaces', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const userId = cryptoRandomString({ length: 10 })
const workspacePlan: PaidWorkspacePlans = 'pro'
const billingInterval: WorkspacePlanBillingIntervals = 'monthly'
const checkoutSession: CheckoutSession = {
id: cryptoRandomString({ length: 10 }),
workspaceId,
userId,
workspacePlan,
url: 'https://example.com',
billingInterval,
paymentStatus: 'unpaid',
currency: 'usd',
createdAt: new Date(),
updatedAt: new Date()
}
let existingCheckoutSession: CheckoutSession | undefined = {
billingInterval: 'monthly',
id: cryptoRandomString({ length: 10 }),
paymentStatus: 'paid',
url: '',
workspaceId,
userId,
workspacePlan: 'team',
currency: 'usd',
createdAt: new Date(),
updatedAt: new Date()
}
let storedCheckoutSession: CheckoutSession | undefined = undefined
const createdCheckoutSession = await startCheckoutSessionFactory({
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
name: 'team',
workspaceId,
status: 'canceled'
}),
getWorkspaceCheckoutSession: async () => existingCheckoutSession!,
countSeatsByTypeInWorkspace: async () => 1,
deleteCheckoutSession: async () => {
existingCheckoutSession = undefined
},
createCheckoutSession: async () => checkoutSession,
saveCheckoutSession: async ({ checkoutSession }) => {
storedCheckoutSession = checkoutSession
}
})({
workspaceId,
userId,
billingInterval,
workspacePlan,
workspaceSlug: cryptoRandomString({ length: 10 }),
isCreateFlow: false,
currency: 'usd'
})
expect(existingCheckoutSession).to.be.undefined
expect(checkoutSession).deep.equal(storedCheckoutSession)
expect(checkoutSession).deep.equal(createdCheckoutSession)
})
})
})