From bcdb5ed0b0b06f7a0c438a5f1fffa82f2e5a4b29 Mon Sep 17 00:00:00 2001 From: Alessandro Magionami Date: Wed, 5 Mar 2025 17:35:28 +0100 Subject: [PATCH] feat(gatekeeper): new checkout flow --- .../modules/gatekeeper/errors/billing.ts | 6 + .../gatekeeper/graph/resolvers/index.ts | 69 ++- .../modules/gatekeeper/services/checkout.ts | 121 +----- .../services/checkout/startCheckoutSession.ts | 275 ++++++++++++ packages/server/modules/gatekeeper/stripe.ts | 11 +- .../gatekeeper/tests/unit/checkout.spec.ts | 410 +++++++++++++++++- .../modules/gatekeeperCore/domain/billing.ts | 2 +- .../server/test/graphql/generated/graphql.ts | 4 +- packages/shared/src/environment/index.ts | 5 - 9 files changed, 740 insertions(+), 163 deletions(-) create mode 100644 packages/server/modules/gatekeeper/services/checkout/startCheckoutSession.ts diff --git a/packages/server/modules/gatekeeper/errors/billing.ts b/packages/server/modules/gatekeeper/errors/billing.ts index c71530d0d..e9667ca00 100644 --- a/packages/server/modules/gatekeeper/errors/billing.ts +++ b/packages/server/modules/gatekeeper/errors/billing.ts @@ -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 +} diff --git a/packages/server/modules/gatekeeper/graph/resolvers/index.ts b/packages/server/modules/gatekeeper/graph/resolvers/index.ts index 708220a7a..79b03010a 100644 --- a/packages/server/modules/gatekeeper/graph/resolvers/index.ts +++ b/packages/server/modules/gatekeeper/graph/resolvers/index.ts @@ -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, diff --git a/packages/server/modules/gatekeeper/services/checkout.ts b/packages/server/modules/gatekeeper/services/checkout.ts index 73221a7c3..1d6ab0f5b 100644 --- a/packages/server/modules/gatekeeper/services/checkout.ts +++ b/packages/server/modules/gatekeeper/services/checkout.ts @@ -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 => { - // 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 = ({ diff --git a/packages/server/modules/gatekeeper/services/checkout/startCheckoutSession.ts b/packages/server/modules/gatekeeper/services/checkout/startCheckoutSession.ts new file mode 100644 index 000000000..f99455427 --- /dev/null +++ b/packages/server/modules/gatekeeper/services/checkout/startCheckoutSession.ts @@ -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 => { + // 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 => { + 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 + } diff --git a/packages/server/modules/gatekeeper/stripe.ts b/packages/server/modules/gatekeeper/stripe.ts index a3cbc1895..12e8db780 100644 --- a/packages/server/modules/gatekeeper/stripe.ts +++ b/packages/server/modules/gatekeeper/stripe.ts @@ -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 & { productId: string } >) }) diff --git a/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts b/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts index c0908d816..2efe6312a 100644 --- a/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts +++ b/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts @@ -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) + }) + }) }) diff --git a/packages/server/modules/gatekeeperCore/domain/billing.ts b/packages/server/modules/gatekeeperCore/domain/billing.ts index b0b519c89..d547fa90f 100644 --- a/packages/server/modules/gatekeeperCore/domain/billing.ts +++ b/packages/server/modules/gatekeeperCore/domain/billing.ts @@ -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 diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index 6f0e1c765..642f13d9d 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -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]; diff --git a/packages/shared/src/environment/index.ts b/packages/shared/src/environment/index.ts index 7c4d0c0c6..27edff8ac 100644 --- a/packages/shared/src/environment/index.ts +++ b/packages/shared/src/environment/index.ts @@ -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()