feat(gatekeeper): new checkout flow

This commit is contained in:
Alessandro Magionami
2025-03-05 17:35:28 +01:00
parent f5a8ab7cbc
commit bcdb5ed0b0
9 changed files with 740 additions and 163 deletions
@@ -59,3 +59,9 @@ export class WorkspaceReadOnlyError extends BaseError {
static code = 'WORKSPACE_READ_ONLY_ERROR'
static statusCode = 403
}
export class InvalidWorkspacePlanUpgradeError extends BaseError {
static defaultMessage = 'Cannot upgrade to the specified workspace plan'
static code = 'INVALID_WORKSPACE_PLAN_UPGRADE_ERROR'
static statusCode = 403
}
@@ -9,7 +9,6 @@ import {
import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace'
import { db } from '@/db/knex'
import {
createCheckoutSessionFactory,
createCustomerPortalUrlFactory,
reconcileWorkspaceSubscriptionFactory
} from '@/modules/gatekeeper/clients/stripe'
@@ -18,7 +17,6 @@ import {
getStripeClient,
getWorkspacePlanProductId
} from '@/modules/gatekeeper/stripe'
import { startCheckoutSessionFactory } from '@/modules/gatekeeper/services/checkout'
import {
deleteCheckoutSessionFactory,
getWorkspaceCheckoutSessionFactory,
@@ -31,19 +29,37 @@ import {
import { canWorkspaceAccessFeatureFactory } from '@/modules/gatekeeper/services/featureAuthorization'
import { upgradeWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/services/subscriptions'
import { isWorkspaceReadOnlyFactory } from '@/modules/gatekeeper/services/readOnly'
import { calculateSubscriptionSeats } from '@/modules/gatekeeper/domain/billing'
import {
calculateSubscriptionSeats,
CreateCheckoutSession,
CreateCheckoutSessionOld
} from '@/modules/gatekeeper/domain/billing'
import { WorkspacePaymentMethod } from '@/test/graphql/generated/graphql'
import { LogicError, NotImplementedError } from '@/modules/shared/errors'
import { isNewPlanType } from '@/modules/gatekeeper/helpers/plans'
import { extendLoggerComponent } from '@/observability/logging'
import { OperationName, OperationStatus } from '@/observability/domain/fields'
import { logWithErr } from '@/observability/utils/logLevels'
import {
createCheckoutSessionFactoryNew,
createCheckoutSessionFactoryOld
} from '@/modules/gatekeeper/clients/checkout/createCheckoutSession'
import {
startCheckoutSessionFactoryNew,
startCheckoutSessionFactoryOld
} from '@/modules/gatekeeper/services/checkout/startCheckoutSession'
import { countSeatsByTypeInWorkspaceFactory } from '@/modules/gatekeeper/repositories/workspaceSeat'
const { FF_GATEKEEPER_MODULE_ENABLED, FF_BILLING_INTEGRATION_ENABLED } =
getFeatureFlags()
const getWorkspacePlan = getWorkspacePlanFactory({ db })
async function shouldUseNewCheckoutFlow(workspaceId: string) {
const workspacePlan = await getWorkspacePlan({ workspaceId })
return workspacePlan && isNewPlanType(workspacePlan.name)
}
export = FF_GATEKEEPER_MODULE_ENABLED
? ({
Workspace: {
@@ -140,7 +156,7 @@ export = FF_GATEKEEPER_MODULE_ENABLED
await deleteCheckoutSessionFactory({ db })({ checkoutSessionId: sessionId })
return true
},
createCheckoutSession: async (parent, args, ctx) => {
createCheckoutSession: async (_parent, args, ctx) => {
let logger = extendLoggerComponent(
ctx.log,
'gatekeeper',
@@ -160,25 +176,40 @@ export = FF_GATEKEEPER_MODULE_ENABLED
Roles.Workspace.Admin,
ctx.resourceAccessRules
)
const createCheckoutSession = createCheckoutSessionFactory({
stripe: getStripeClient(),
frontendOrigin: getFrontendOrigin(),
getWorkspacePlanPrice
})
const createCheckoutSession = (await shouldUseNewCheckoutFlow(workspaceId))
? createCheckoutSessionFactoryNew({
stripe: getStripeClient(),
frontendOrigin: getFrontendOrigin(),
getWorkspacePlanPrice
})
: createCheckoutSessionFactoryOld({
stripe: getStripeClient(),
frontendOrigin: getFrontendOrigin(),
getWorkspacePlanPrice
})
const countRole = countWorkspaceRoleWithOptionalProjectRoleFactory({ db })
const startCheckoutSession = (await shouldUseNewCheckoutFlow(workspaceId))
? startCheckoutSessionFactoryNew({
getWorkspaceCheckoutSession: getWorkspaceCheckoutSessionFactory({ db }),
getWorkspacePlan: getWorkspacePlanFactory({ db }),
countSeatsByTypeInWorkspace: countSeatsByTypeInWorkspaceFactory({ db }),
createCheckoutSession: createCheckoutSession as CreateCheckoutSession,
saveCheckoutSession: saveCheckoutSessionFactory({ db }),
deleteCheckoutSession: deleteCheckoutSessionFactory({ db })
})
: startCheckoutSessionFactoryOld({
getWorkspaceCheckoutSession: getWorkspaceCheckoutSessionFactory({ db }),
getWorkspacePlan: getWorkspacePlanFactory({ db }),
countRole,
createCheckoutSession:
createCheckoutSession as CreateCheckoutSessionOld,
saveCheckoutSession: saveCheckoutSessionFactory({ db }),
deleteCheckoutSession: deleteCheckoutSessionFactory({ db })
})
try {
logger.info(OperationStatus.start, '[{operationName} ({operationStatus})]')
const session = await startCheckoutSessionFactory({
getWorkspaceCheckoutSession: getWorkspaceCheckoutSessionFactory({ db }),
getWorkspacePlan: getWorkspacePlanFactory({ db }),
countRole,
createCheckoutSession,
saveCheckoutSession: saveCheckoutSessionFactory({ db }),
deleteCheckoutSession: deleteCheckoutSessionFactory({ db })
})({
const session = await startCheckoutSession({
workspacePlan,
workspaceId,
workspaceSlug: workspace.slug,
@@ -1,131 +1,16 @@
import {
CheckoutSession,
CreateCheckoutSession,
GetCheckoutSession,
GetWorkspacePlan,
SaveCheckoutSession,
UpdateCheckoutSessionStatus,
UpsertWorkspaceSubscription,
UpsertPaidWorkspacePlan,
GetSubscriptionData,
GetWorkspaceCheckoutSession,
DeleteCheckoutSession
GetSubscriptionData
} from '@/modules/gatekeeper/domain/billing'
import {
CheckoutSessionNotFoundError,
WorkspaceAlreadyPaidError,
WorkspaceCheckoutSessionInProgressError
WorkspaceAlreadyPaidError
} from '@/modules/gatekeeper/errors/billing'
import { throwUncoveredError } from '@speckle/shared'
import { EventBusEmit } from '@/modules/shared/services/eventBus'
import { CountWorkspaceRoleWithOptionalProjectRole } from '@/modules/workspaces/domain/operations'
import {
PaidWorkspacePlans,
Roles,
throwUncoveredError,
WorkspacePlanBillingIntervals
} from '@speckle/shared'
export const startCheckoutSessionFactory =
({
getWorkspaceCheckoutSession,
deleteCheckoutSession,
getWorkspacePlan,
countRole,
createCheckoutSession,
saveCheckoutSession
}: {
getWorkspaceCheckoutSession: GetWorkspaceCheckoutSession
deleteCheckoutSession: DeleteCheckoutSession
getWorkspacePlan: GetWorkspacePlan
countRole: CountWorkspaceRoleWithOptionalProjectRole
createCheckoutSession: CreateCheckoutSession
saveCheckoutSession: SaveCheckoutSession
}) =>
async ({
workspaceId,
workspaceSlug,
workspacePlan,
billingInterval,
isCreateFlow
}: {
workspaceId: string
workspaceSlug: string
workspacePlan: PaidWorkspacePlans
billingInterval: WorkspacePlanBillingIntervals
isCreateFlow: boolean
}): Promise<CheckoutSession> => {
// get workspace plan, if we're already on a paid plan, do not allow checkout
// paid plans should use a subscription modification
const existingWorkspacePlan = await getWorkspacePlan({ workspaceId })
// it will technically not be possible to not have
if (existingWorkspacePlan) {
// maybe we can just ignore the plan not existing, cause we're putting it on a plan post checkout
switch (existingWorkspacePlan.status) {
// valid and paymentFailed, but not canceled status is not something we need a checkout for
// we already have their credit card info
case 'valid':
case 'paymentFailed':
case 'cancelationScheduled':
throw new WorkspaceAlreadyPaidError()
case 'canceled':
const existingCheckoutSession = await getWorkspaceCheckoutSession({
workspaceId
})
if (existingCheckoutSession)
await deleteCheckoutSession({
checkoutSessionId: existingCheckoutSession?.id
})
break
// maybe, we can reactivate canceled plans via the sub in stripe, but this is fine too
// it will create a new customer and a new sub though, the reactivation would use the existing customer
case 'trial':
case 'expired':
// lets go ahead and pay
break
default:
throwUncoveredError(existingWorkspacePlan)
}
}
// if there is already a checkout session for the workspace, stop, someone else is maybe trying to pay for the workspace
const workspaceCheckoutSession = await getWorkspaceCheckoutSession({
workspaceId
})
if (workspaceCheckoutSession) {
if (workspaceCheckoutSession.paymentStatus === 'paid')
// this is should not be possible, but its better to be checking it here, than double charging the customer
throw new WorkspaceAlreadyPaidError()
if (new Date().getTime() - workspaceCheckoutSession.createdAt.getTime() > 1000) {
await deleteCheckoutSession({
checkoutSessionId: workspaceCheckoutSession.id
})
} else {
throw new WorkspaceCheckoutSessionInProgressError()
}
}
const [adminCount, memberCount, guestCount] = await Promise.all([
countRole({ workspaceId, workspaceRole: Roles.Workspace.Admin }),
countRole({ workspaceId, workspaceRole: Roles.Workspace.Member }),
countRole({ workspaceId, workspaceRole: Roles.Workspace.Guest })
])
const checkoutSession = await createCheckoutSession({
workspaceId,
workspaceSlug,
billingInterval,
workspacePlan,
guestCount,
seatCount: adminCount + memberCount,
isCreateFlow
})
await saveCheckoutSession({ checkoutSession })
return checkoutSession
}
export const completeCheckoutSessionFactory =
({
@@ -0,0 +1,275 @@
import {
CheckoutSession,
CreateCheckoutSession,
CreateCheckoutSessionOld,
DeleteCheckoutSession,
GetWorkspaceCheckoutSession,
GetWorkspacePlan,
SaveCheckoutSession
} from '@/modules/gatekeeper/domain/billing'
import { CountSeatsByTypeInWorkspace } from '@/modules/gatekeeper/domain/operations'
import {
InvalidWorkspacePlanUpgradeError,
WorkspaceAlreadyPaidError,
WorkspaceCheckoutSessionInProgressError
} from '@/modules/gatekeeper/errors/billing'
import { NotFoundError } from '@/modules/shared/errors'
import { CountWorkspaceRoleWithOptionalProjectRole } from '@/modules/workspaces/domain/operations'
import {
PaidWorkspacePlans,
Roles,
throwUncoveredError,
WorkspacePlanBillingIntervals,
WorkspacePlans
} from '@speckle/shared'
import { z } from 'zod'
export const startCheckoutSessionFactoryOld =
({
getWorkspaceCheckoutSession,
deleteCheckoutSession,
getWorkspacePlan,
countRole,
createCheckoutSession,
saveCheckoutSession
}: {
getWorkspaceCheckoutSession: GetWorkspaceCheckoutSession
deleteCheckoutSession: DeleteCheckoutSession
getWorkspacePlan: GetWorkspacePlan
countRole: CountWorkspaceRoleWithOptionalProjectRole
createCheckoutSession: CreateCheckoutSessionOld
saveCheckoutSession: SaveCheckoutSession
}) =>
async ({
workspaceId,
workspaceSlug,
workspacePlan,
billingInterval,
isCreateFlow
}: {
workspaceId: string
workspaceSlug: string
workspacePlan: PaidWorkspacePlans
billingInterval: WorkspacePlanBillingIntervals
isCreateFlow: boolean
}): Promise<CheckoutSession> => {
// get workspace plan, if we're already on a paid plan, do not allow checkout
// paid plans should use a subscription modification
const existingWorkspacePlan = await getWorkspacePlan({ workspaceId })
if (existingWorkspacePlan) {
// it will technically not be possible to not have
// maybe we can just ignore the plan not existing, cause we're putting it on a plan post checkout
switch (existingWorkspacePlan.status) {
// valid and paymentFailed, but not canceled status is not something we need a checkout for
// we already have their credit card info
case 'valid':
case 'paymentFailed':
case 'cancelationScheduled':
throw new WorkspaceAlreadyPaidError()
case 'canceled':
const existingCheckoutSession = await getWorkspaceCheckoutSession({
workspaceId
})
if (existingCheckoutSession)
await deleteCheckoutSession({
checkoutSessionId: existingCheckoutSession?.id
})
break
// maybe, we can reactivate canceled plans via the sub in stripe, but this is fine too
// it will create a new customer and a new sub though, the reactivation would use the existing customer
case 'trial':
case 'expired':
// lets go ahead and pay
break
default:
throwUncoveredError(existingWorkspacePlan)
}
}
// if there is already a checkout session for the workspace, stop, someone else is maybe trying to pay for the workspace
const workspaceCheckoutSession = await getWorkspaceCheckoutSession({
workspaceId
})
if (workspaceCheckoutSession) {
if (workspaceCheckoutSession.paymentStatus === 'paid')
// this is should not be possible, but its better to be checking it here, than double charging the customer
throw new WorkspaceAlreadyPaidError()
if (
new Date().getTime() - workspaceCheckoutSession.createdAt.getTime() >
1000
// 10 * 60 * 1000
) {
await deleteCheckoutSession({
checkoutSessionId: workspaceCheckoutSession.id
})
} else {
throw new WorkspaceCheckoutSessionInProgressError()
}
}
const [adminCount, memberCount, guestCount] = await Promise.all([
countRole({ workspaceId, workspaceRole: Roles.Workspace.Admin }),
countRole({ workspaceId, workspaceRole: Roles.Workspace.Member }),
countRole({ workspaceId, workspaceRole: Roles.Workspace.Guest })
])
const checkoutSession = await createCheckoutSession({
workspaceId,
workspaceSlug,
billingInterval,
workspacePlan,
guestCount,
seatCount: adminCount + memberCount,
isCreateFlow
})
await saveCheckoutSession({ checkoutSession })
return checkoutSession
}
const WorkspacePlansUpgradeMapping = z.union([
z.object({
current: z.literal('free'),
upgrade: z.union([z.literal('team'), z.literal('pro')])
}),
z.object({
current: z.literal('team'),
upgrade: z.literal('pro')
})
])
const isUpgradeWorkspacePlanValid = ({
current,
upgrade
}: {
current: WorkspacePlans
upgrade: WorkspacePlans
}): boolean => {
return WorkspacePlansUpgradeMapping.safeParse({ current, upgrade }).success
}
export const startCheckoutSessionFactoryNew =
({
getWorkspaceCheckoutSession,
deleteCheckoutSession,
getWorkspacePlan,
countSeatsByTypeInWorkspace,
createCheckoutSession,
saveCheckoutSession
}: {
getWorkspaceCheckoutSession: GetWorkspaceCheckoutSession
deleteCheckoutSession: DeleteCheckoutSession
getWorkspacePlan: GetWorkspacePlan
countSeatsByTypeInWorkspace: CountSeatsByTypeInWorkspace
createCheckoutSession: CreateCheckoutSession
saveCheckoutSession: SaveCheckoutSession
}) =>
async ({
workspaceId,
workspaceSlug,
workspacePlan,
billingInterval,
isCreateFlow
}: {
workspaceId: string
workspaceSlug: string
workspacePlan: PaidWorkspacePlans
billingInterval: WorkspacePlanBillingIntervals
isCreateFlow: boolean
}): Promise<CheckoutSession> => {
const existingWorkspacePlan = await getWorkspacePlan({ workspaceId })
if (!existingWorkspacePlan) {
// New plans are enabled so we assume a plan is always present (the free plan)
throw new NotFoundError('Workspace does not have a plan', {
info: { workspaceId }
})
}
const upgradeValid = isUpgradeWorkspacePlanValid({
current: existingWorkspacePlan.name,
upgrade: workspacePlan
})
if (!upgradeValid) {
throw new InvalidWorkspacePlanUpgradeError(null, {
info: {
workspaceId,
currentPlan: existingWorkspacePlan.name,
upgradePlan: workspacePlan
}
})
}
// it will technically not be possible to not have
// maybe we can just ignore the plan not existing, cause we're putting it on a plan post checkout
switch (existingWorkspacePlan.status) {
// valid and paymentFailed, but not canceled status is not something we need a checkout for
// we already have their credit card info
case 'valid':
case 'paymentFailed':
case 'cancelationScheduled':
if (existingWorkspacePlan.name === 'free') {
break
}
throw new WorkspaceAlreadyPaidError()
case 'canceled':
const existingCheckoutSession = await getWorkspaceCheckoutSession({
workspaceId
})
if (existingCheckoutSession)
await deleteCheckoutSession({
checkoutSessionId: existingCheckoutSession?.id
})
break
// maybe, we can reactivate canceled plans via the sub in stripe, but this is fine too
// it will create a new customer and a new sub though, the reactivation would use the existing customer
case 'trial':
case 'expired':
// lets go ahead and pay
break
default:
throwUncoveredError(existingWorkspacePlan)
}
// if there is already a checkout session for the workspace, stop, someone else is maybe trying to pay for the workspace
const workspaceCheckoutSession = await getWorkspaceCheckoutSession({
workspaceId
})
if (workspaceCheckoutSession) {
if (workspaceCheckoutSession.paymentStatus === 'paid')
// this is should not be possible, but its better to be checking it here, than double charging the customer
throw new WorkspaceAlreadyPaidError()
if (
new Date().getTime() - workspaceCheckoutSession.createdAt.getTime() >
1000
// 10 * 60 * 1000
) {
await deleteCheckoutSession({
checkoutSessionId: workspaceCheckoutSession.id
})
} else {
throw new WorkspaceCheckoutSessionInProgressError()
}
}
const [editorsCount, viewersCount] = await Promise.all([
countSeatsByTypeInWorkspace({ workspaceId, type: 'editor' }),
countSeatsByTypeInWorkspace({ workspaceId, type: 'viewer' })
])
const checkoutSession = await createCheckoutSession({
workspaceId,
workspaceSlug,
billingInterval,
workspacePlan,
viewersCount,
editorsCount,
isCreateFlow
})
await saveCheckoutSession({ checkoutSession })
return checkoutSession
}
+8 -3
View File
@@ -18,7 +18,7 @@ export const getStripeClient = () => {
return stripeClient
}
const { FF_WORKSPACES_NEW_PLAN_ENABLED } = getFeatureFlags()
const { FF_WORKSPACES_NEW_PLANS_ENABLED } = getFeatureFlags()
export const workspacePlanPrices = (): Record<
WorkspacePricingProducts,
@@ -46,8 +46,13 @@ export const workspacePlanPrices = (): Record<
yearly: getStringFromEnv('WORKSPACE_YEARLY_BUSINESS_SEAT_STRIPE_PRICE_ID')
},
// new
...((FF_WORKSPACES_NEW_PLAN_ENABLED
...((FF_WORKSPACES_NEW_PLANS_ENABLED
? {
viewer: {
productId: getStringFromEnv('WORKSPACE_VIEWER_SEAT_STRIPE_PRODUCT_ID'),
monthly: getStringFromEnv('WORKSPACE_MONTHLY_VIEWER_SEAT_STRIPE_PRICE_ID'),
yearly: getStringFromEnv('WORKSPACE_YEARLY_VIEWER_SEAT_STRIPE_PRICE_ID')
},
team: {
productId: getStringFromEnv('WORKSPACE_TEAM_SEAT_STRIPE_PRODUCT_ID'),
monthly: getStringFromEnv('WORKSPACE_MONTHLY_TEAM_SEAT_STRIPE_PRICE_ID'),
@@ -60,7 +65,7 @@ export const workspacePlanPrices = (): Record<
}
}
: {}) as Record<
'team' | 'pro',
'viewer' | 'team' | 'pro',
Record<WorkspacePlanBillingIntervals, string> & { productId: string }
>)
})
@@ -1,12 +1,10 @@
import {
CheckoutSessionNotFoundError,
InvalidWorkspacePlanUpgradeError,
WorkspaceAlreadyPaidError,
WorkspaceCheckoutSessionInProgressError
} from '@/modules/gatekeeper/errors/billing'
import {
completeCheckoutSessionFactory,
startCheckoutSessionFactory
} from '@/modules/gatekeeper/services/checkout'
import { completeCheckoutSessionFactory } from '@/modules/gatekeeper/services/checkout'
import { expectToThrow } from '@/test/assertionHelper'
import { expect } from 'chai'
import cryptoRandomString from 'crypto-random-string'
@@ -18,13 +16,18 @@ import {
import { omit } from 'lodash'
import { PaidWorkspacePlan } from '@/modules/gatekeeperCore/domain/billing'
import { PaidWorkspacePlans, WorkspacePlanBillingIntervals } from '@speckle/shared'
import {
startCheckoutSessionFactoryNew as startCheckoutSessionFactory,
startCheckoutSessionFactoryOld
} from '@/modules/gatekeeper/services/checkout/startCheckoutSession'
import { NotFoundError } from '@/modules/shared/errors'
describe('checkout @gatekeeper', () => {
describe('startCheckoutSessionFactory creates a function, that', () => {
describe('startCheckoutSessionFactoryOld creates a function, that', () => {
it('does not allow checkout for workspace plans, that is in a valid state', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const err = await expectToThrow(() =>
startCheckoutSessionFactory({
startCheckoutSessionFactoryOld({
getWorkspacePlan: async () => ({
name: 'plus',
status: 'valid',
@@ -59,7 +62,7 @@ describe('checkout @gatekeeper', () => {
it('does not allow checkout for workspace plans, that is in a paymentFailed state', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const err = await expectToThrow(() =>
startCheckoutSessionFactory({
startCheckoutSessionFactoryOld({
getWorkspacePlan: async () => ({
name: 'plus',
status: 'paymentFailed',
@@ -94,7 +97,7 @@ describe('checkout @gatekeeper', () => {
it('does not allow checkout for a workspace, that already has a recent checkout session', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const err = await expectToThrow(() =>
startCheckoutSessionFactory({
startCheckoutSessionFactoryOld({
getWorkspacePlan: async () => ({
name: 'starter',
status: 'trial',
@@ -139,7 +142,7 @@ describe('checkout @gatekeeper', () => {
it('does not allow checkout for a workspace, that already has a checkout session', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const err = await expectToThrow(() =>
startCheckoutSessionFactory({
startCheckoutSessionFactoryOld({
getWorkspacePlan: async () => ({
name: 'starter',
status: 'trial',
@@ -197,7 +200,7 @@ describe('checkout @gatekeeper', () => {
updatedAt: new Date()
}
let storedCheckoutSession: CheckoutSession | undefined = undefined
const createdCheckoutSession = await startCheckoutSessionFactory({
const createdCheckoutSession = await startCheckoutSessionFactoryOld({
getWorkspacePlan: async () => null,
getWorkspaceCheckoutSession: async () => null,
countRole: async () => 1,
@@ -233,7 +236,7 @@ describe('checkout @gatekeeper', () => {
updatedAt: new Date()
}
let storedCheckoutSession: CheckoutSession | undefined = undefined
const createdCheckoutSession = await startCheckoutSessionFactory({
const createdCheckoutSession = await startCheckoutSessionFactoryOld({
getWorkspacePlan: async () => null,
getWorkspaceCheckoutSession: async () => null,
countRole: async () => 1,
@@ -270,7 +273,7 @@ describe('checkout @gatekeeper', () => {
updatedAt: new Date()
}
let storedCheckoutSession: CheckoutSession | undefined = undefined
const createdCheckoutSession = await startCheckoutSessionFactory({
const createdCheckoutSession = await startCheckoutSessionFactoryOld({
getWorkspacePlan: async () => ({
workspaceId,
name: 'starter',
@@ -322,7 +325,7 @@ describe('checkout @gatekeeper', () => {
workspacePlan
}
let storedCheckoutSession: CheckoutSession | undefined = undefined
const createdCheckoutSession = await startCheckoutSessionFactory({
const createdCheckoutSession = await startCheckoutSessionFactoryOld({
getWorkspacePlan: async () => ({
workspaceId,
name: 'starter',
@@ -365,7 +368,7 @@ describe('checkout @gatekeeper', () => {
workspacePlan
}
const err = await expectToThrow(async () => {
await startCheckoutSessionFactory({
await startCheckoutSessionFactoryOld({
getWorkspacePlan: async () => ({
workspaceId,
name: 'starter',
@@ -407,7 +410,7 @@ describe('checkout @gatekeeper', () => {
workspacePlan
}
const err = await expectToThrow(async () => {
await startCheckoutSessionFactory({
await startCheckoutSessionFactoryOld({
getWorkspacePlan: async () => ({
workspaceId,
name: 'starter',
@@ -461,7 +464,7 @@ describe('checkout @gatekeeper', () => {
updatedAt: new Date()
}
let storedCheckoutSession: CheckoutSession | undefined = undefined
const createdCheckoutSession = await startCheckoutSessionFactory({
const createdCheckoutSession = await startCheckoutSessionFactoryOld({
getWorkspacePlan: async () => ({
name: 'plus',
workspaceId,
@@ -489,6 +492,7 @@ describe('checkout @gatekeeper', () => {
expect(checkoutSession).deep.equal(createdCheckoutSession)
})
})
describe('completeCheckoutSessionFactory creates a function, that', () => {
it('throws a CheckoutSessionNotFound if the checkoutSession is null', async () => {
const sessionId = cryptoRandomString({ length: 10 })
@@ -651,4 +655,378 @@ describe('checkout @gatekeeper', () => {
})
})
})
describe('startCheckoutSessionFactory creates a function, that', () => {
it('does not allow checkout if workspace plan does not exists', async () => {
const workspaceId = 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,
billingInterval: 'monthly',
workspacePlan: 'pro',
workspaceSlug: cryptoRandomString({ length: 10 }),
isCreateFlow: false
})
)
expect(err.name).to.be.equal(new NotFoundError().name)
})
it('does not allow checkout from old workspace plans', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const err = await expectToThrow(() =>
startCheckoutSessionFactory({
getWorkspacePlan: async () => ({
name: 'plus',
status: 'valid',
createdAt: new Date(),
workspaceId
}),
getWorkspaceCheckoutSession: () => {
expect.fail()
},
countSeatsByTypeInWorkspace: () => {
expect.fail()
},
createCheckoutSession: () => {
expect.fail()
},
saveCheckoutSession: () => {
expect.fail()
},
deleteCheckoutSession: () => {
expect.fail()
}
})({
workspaceId,
billingInterval: 'monthly',
workspacePlan: 'pro',
workspaceSlug: cryptoRandomString({ length: 10 }),
isCreateFlow: false
})
)
expect(err.name).to.be.equal(new InvalidWorkspacePlanUpgradeError().name)
})
it('does not allow checkout for paid workspace plans, that is in a valid state', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const err = await expectToThrow(() =>
startCheckoutSessionFactory({
getWorkspacePlan: async () => ({
name: 'team',
status: 'valid',
createdAt: new Date(),
workspaceId
}),
getWorkspaceCheckoutSession: () => {
expect.fail()
},
countSeatsByTypeInWorkspace: () => {
expect.fail()
},
createCheckoutSession: () => {
expect.fail()
},
saveCheckoutSession: () => {
expect.fail()
},
deleteCheckoutSession: () => {
expect.fail()
}
})({
workspaceId,
billingInterval: 'monthly',
workspacePlan: 'pro',
workspaceSlug: cryptoRandomString({ length: 10 }),
isCreateFlow: false
})
)
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 err = await expectToThrow(() =>
startCheckoutSessionFactory({
getWorkspacePlan: async () => ({
name: 'team',
status: 'paymentFailed',
createdAt: new Date(),
workspaceId
}),
getWorkspaceCheckoutSession: () => {
expect.fail()
},
countSeatsByTypeInWorkspace: () => {
expect.fail()
},
createCheckoutSession: () => {
expect.fail()
},
deleteCheckoutSession: () => {
expect.fail()
},
saveCheckoutSession: () => {
expect.fail()
}
})({
workspaceId,
billingInterval: 'monthly',
workspacePlan: 'pro',
workspaceSlug: cryptoRandomString({ length: 10 }),
isCreateFlow: false
})
)
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 err = await expectToThrow(() =>
startCheckoutSessionFactory({
getWorkspacePlan: async () => ({
name: 'free',
status: 'valid',
createdAt: new Date(),
workspaceId
}),
getWorkspaceCheckoutSession: async () => ({
billingInterval: 'monthly',
id: cryptoRandomString({ length: 10 }),
paymentStatus: 'unpaid',
url: '',
workspaceId,
workspacePlan: 'business',
createdAt: new Date(),
updatedAt: new Date()
}),
countSeatsByTypeInWorkspace: () => {
expect.fail()
},
createCheckoutSession: () => {
expect.fail()
},
deleteCheckoutSession: () => {
expect.fail()
},
saveCheckoutSession: () => {
expect.fail()
}
})({
workspaceId,
billingInterval: 'monthly',
workspacePlan: 'team',
workspaceSlug: cryptoRandomString({ length: 10 }),
isCreateFlow: false
})
)
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 workspacePlan: PaidWorkspacePlans = 'pro'
const billingInterval: WorkspacePlanBillingIntervals = 'monthly'
const checkoutSession: CheckoutSession = {
id: cryptoRandomString({ length: 10 }),
workspaceId,
workspacePlan,
url: 'https://example.com',
billingInterval,
paymentStatus: 'unpaid',
createdAt: new Date(),
updatedAt: new Date()
}
let storedCheckoutSession: CheckoutSession | undefined = undefined
const createdCheckoutSession = await startCheckoutSessionFactory({
getWorkspacePlan: async () => ({
workspaceId,
name: 'free',
createdAt: new Date(),
status: 'valid'
}),
getWorkspaceCheckoutSession: async () => null,
countSeatsByTypeInWorkspace: async () => 1,
deleteCheckoutSession: () => {
expect.fail()
},
createCheckoutSession: async () => checkoutSession,
saveCheckoutSession: async ({ checkoutSession }) => {
storedCheckoutSession = checkoutSession
}
})({
workspaceId,
billingInterval,
workspacePlan,
workspaceSlug: cryptoRandomString({ length: 10 }),
isCreateFlow: false
})
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 workspacePlan: PaidWorkspacePlans = 'team'
const billingInterval: WorkspacePlanBillingIntervals = 'monthly'
const checkoutSession: CheckoutSession = {
id: cryptoRandomString({ length: 10 }),
workspaceId,
workspacePlan,
url: 'https://example.com',
billingInterval,
paymentStatus: 'unpaid',
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',
url: 'https://example.com',
workspaceId,
workspacePlan
}
let storedCheckoutSession: CheckoutSession | undefined = undefined
const createdCheckoutSession = await startCheckoutSessionFactory({
getWorkspacePlan: async () => ({
workspaceId,
name: 'free',
status: 'valid',
createdAt: new Date()
}),
getWorkspaceCheckoutSession: async () => existingCheckoutSession!,
countSeatsByTypeInWorkspace: async () => 1,
deleteCheckoutSession: async () => {
existingCheckoutSession = undefined
},
createCheckoutSession: async () => checkoutSession,
saveCheckoutSession: async ({ checkoutSession }) => {
storedCheckoutSession = checkoutSession
}
})({
workspaceId,
billingInterval,
workspacePlan,
workspaceSlug: cryptoRandomString({ length: 10 }),
isCreateFlow: false
})
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 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',
workspaceId,
workspacePlan
}
const err = await expectToThrow(async () => {
await startCheckoutSessionFactory({
getWorkspacePlan: async () => ({
workspaceId,
name: 'free',
createdAt: new Date(),
status: 'valid'
}),
getWorkspaceCheckoutSession: async () => existingCheckoutSession!,
countSeatsByTypeInWorkspace: async () => 1,
deleteCheckoutSession: async () => {
existingCheckoutSession = undefined
},
createCheckoutSession: async () => {
expect.fail()
},
saveCheckoutSession: async () => {}
})({
workspaceId,
billingInterval,
workspacePlan,
workspaceSlug: cryptoRandomString({ length: 10 }),
isCreateFlow: false
})
})
expect(err.message).to.equal(new WorkspaceAlreadyPaidError().message)
})
it('creates and stores a checkout for CANCELED workspaces', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const workspacePlan: PaidWorkspacePlans = 'pro'
const billingInterval: WorkspacePlanBillingIntervals = 'monthly'
const checkoutSession: CheckoutSession = {
id: cryptoRandomString({ length: 10 }),
workspaceId,
workspacePlan,
url: 'https://example.com',
billingInterval,
paymentStatus: 'unpaid',
createdAt: new Date(),
updatedAt: new Date()
}
let existingCheckoutSession: CheckoutSession | undefined = {
billingInterval: 'monthly',
id: cryptoRandomString({ length: 10 }),
paymentStatus: 'paid',
url: '',
workspaceId,
workspacePlan: 'team',
createdAt: new Date(),
updatedAt: new Date()
}
let storedCheckoutSession: CheckoutSession | undefined = undefined
const createdCheckoutSession = await startCheckoutSessionFactory({
getWorkspacePlan: async () => ({
name: 'team',
workspaceId,
createdAt: new Date(),
status: 'canceled'
}),
getWorkspaceCheckoutSession: async () => existingCheckoutSession!,
countSeatsByTypeInWorkspace: async () => 1,
deleteCheckoutSession: async () => {
existingCheckoutSession = undefined
},
createCheckoutSession: async () => checkoutSession,
saveCheckoutSession: async ({ checkoutSession }) => {
storedCheckoutSession = checkoutSession
}
})({
workspaceId,
billingInterval,
workspacePlan,
workspaceSlug: cryptoRandomString({ length: 10 }),
isCreateFlow: false
})
expect(existingCheckoutSession).to.be.undefined
expect(checkoutSession).deep.equal(storedCheckoutSession)
expect(checkoutSession).deep.equal(createdCheckoutSession)
})
})
})
@@ -10,7 +10,7 @@ import {
/**
* This includes the pricing plans (Stripe products) a customer can sub to
*/
export type WorkspacePricingProducts = PaidWorkspacePlans | 'guest'
export type WorkspacePricingProducts = PaidWorkspacePlans | 'guest' | 'viewer'
type BaseWorkspacePlan = {
workspaceId: string
@@ -1876,7 +1876,9 @@ export type OnboardingCompletionInput = {
export const PaidWorkspacePlans = {
Business: 'business',
Plus: 'plus',
Starter: 'starter'
Pro: 'pro',
Starter: 'starter',
Team: 'team'
} as const;
export type PaidWorkspacePlans = typeof PaidWorkspacePlans[keyof typeof PaidWorkspacePlans];
-5
View File
@@ -80,10 +80,6 @@ const parseFeatureFlags = () => {
FF_MOVE_PROJECT_REGION_ENABLED: {
schema: z.boolean(),
defaults: { production: false, _: true }
},
FF_WORKSPACES_NEW_PLAN_ENABLED: {
schema: z.boolean(),
defaults: { production: false, _: false }
}
})
@@ -113,7 +109,6 @@ export function getFeatureFlags(): {
FF_FORCE_ONBOARDING: boolean
FF_OBJECTS_STREAMING_FIX: boolean
FF_MOVE_PROJECT_REGION_ENABLED: boolean
FF_WORKSPACES_NEW_PLAN_ENABLED: boolean
FF_NO_PERSONAL_EMAILS_ENABLED: boolean
} {
if (!parsedFlags) parsedFlags = parseFeatureFlags()