diff --git a/.circleci/config.yml b/.circleci/config.yml index c823ddba8a..75c86289fc 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -19,6 +19,7 @@ workflows: - test-server: context: - speckle-server-licensing + - stripe-integration filters: &filters-allow-all tags: # run tests for any commit on any branch, including any tags @@ -464,6 +465,7 @@ jobs: REDIS_URL: 'redis://127.0.0.1:6379' S3_REGION: '' # optional, defaults to 'us-east-1' AUTOMATE_ENCRYPTION_KEYS_PATH: 'test/assets/automate/encryptionKeys.json' + FF_BILLING_INTEGRATION_ENABLED: 'true' steps: - checkout - restore_cache: @@ -550,6 +552,8 @@ jobs: FF_WORKSPACES_SSO_ENABLED: 'false' FF_MULTIPLE_EMAILS_MODULE_ENABLED: 'false' FF_GENDOAI_MODULE_ENABLED: 'false' + FF_GATEKEEPER_MODULE_ENABLED: 'false' + FF_BILLING_INTEGRATION_ENABLED: 'false' test-frontend-2: docker: &docker-node-browsers-image diff --git a/packages/frontend-2/components/settings/workspaces/Billing.vue b/packages/frontend-2/components/settings/workspaces/Billing.vue index 16b57ce535..21dde39be3 100644 --- a/packages/frontend-2/components/settings/workspaces/Billing.vue +++ b/packages/frontend-2/components/settings/workspaces/Billing.vue @@ -57,6 +57,25 @@ + +
+ +
+
+

Billing cycle

+

+ Choose an annual billing cycle for 20% off +

+
+ +
+
Add the pricing table here
+
+ Team plan + Pro plan + Business plan +
+
@@ -65,6 +84,7 @@ import { graphql } from '~/lib/common/generated/gql' import { useQuery } from '@vue/apollo-composable' import { settingsWorkspaceBillingQuery } from '~/lib/settings/graphql/queries' +import { useIsBillingIntegrationEnabled } from '~/composables/globals' graphql(` fragment SettingsWorkspacesBilling_Workspace on Workspace { @@ -86,6 +106,24 @@ const props = defineProps<{ workspaceId: string }>() +const isBillingIntegrationEnabled = useIsBillingIntegrationEnabled() +const isYearlyPlan = ref(false) + +const checkoutUrl = (plan: string) => + `/api/v1/billing/workspaces/${ + props.workspaceId + }/checkout-session/${plan}/${billingCycle()}` +const billingCycle = () => (isYearlyPlan.value ? 'yearly' : 'monthly') +const teamCheckout = () => { + window.location.href = checkoutUrl('team') +} +const proCheckout = () => { + window.location.href = checkoutUrl('pro') +} +const businessCheckout = () => { + window.location.href = checkoutUrl('business') +} + const { result } = useQuery(settingsWorkspaceBillingQuery, () => ({ workspaceId: props.workspaceId })) diff --git a/packages/frontend-2/composables/globals.ts b/packages/frontend-2/composables/globals.ts index 60832ae026..088b36e65d 100644 --- a/packages/frontend-2/composables/globals.ts +++ b/packages/frontend-2/composables/globals.ts @@ -41,4 +41,11 @@ export const useIsGendoModuleEnabled = () => { return ref(FF_GENDOAI_MODULE_ENABLED) } +export const useIsBillingIntegrationEnabled = () => { + const { + public: { FF_BILLING_INTEGRATION_ENABLED } + } = useRuntimeConfig() + return ref(FF_BILLING_INTEGRATION_ENABLED) +} + export { useGlobalToast, useActiveUser, usePageQueryStandardFetchPolicy } diff --git a/packages/frontend-2/lib/common/generated/gql/graphql.ts b/packages/frontend-2/lib/common/generated/gql/graphql.ts index 09134fc744..c916cc0391 100644 --- a/packages/frontend-2/lib/common/generated/gql/graphql.ts +++ b/packages/frontend-2/lib/common/generated/gql/graphql.ts @@ -2501,6 +2501,7 @@ export type Query = { * Either token or workspaceId must be specified, or both */ workspaceInvite?: Maybe; + workspacePricingPlans: Scalars['JSONObject']['output']; }; @@ -6936,6 +6937,7 @@ export type QueryFieldArgs = { workspace: QueryWorkspaceArgs, workspaceBySlug: QueryWorkspaceBySlugArgs, workspaceInvite: QueryWorkspaceInviteArgs, + workspacePricingPlans: {}, } export type ResourceIdentifierFieldArgs = { resourceId: {}, diff --git a/packages/server/app.ts b/packages/server/app.ts index 6713a4ee15..431fd0493c 100644 --- a/packages/server/app.ts +++ b/packages/server/app.ts @@ -365,7 +365,15 @@ export async function init() { } app.use(corsMiddleware()) - app.use(express.json({ limit: '100mb' })) + // there are some paths, that need the raw body + app.use((req, res, next) => { + const rawPaths = ['/api/v1/billing/webhooks'] + if (rawPaths.includes(req.path)) { + express.raw({ type: 'application/json' })(req, res, next) + } else { + express.json({ limit: '100mb' })(req, res, next) + } + }) app.use(express.urlencoded({ limit: `${getFileSizeLimitMB()}mb`, extended: false })) // Trust X-Forwarded-* headers (for https protocol detection) diff --git a/packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql b/packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql new file mode 100644 index 0000000000..226d429392 --- /dev/null +++ b/packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql @@ -0,0 +1,3 @@ +extend type Query { + workspacePricingPlans: JSONObject! +} diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index f482f2d4f6..526801248c 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -2520,6 +2520,7 @@ export type Query = { * Either token or workspaceId must be specified, or both */ workspaceInvite?: Maybe; + workspacePricingPlans: Scalars['JSONObject']['output']; }; @@ -5702,6 +5703,7 @@ export type QueryResolvers>; workspaceBySlug?: Resolver>; workspaceInvite?: Resolver, ParentType, ContextType, Partial>; + workspacePricingPlans?: Resolver; }; export type ResourceIdentifierResolvers = { diff --git a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts index b1a4870867..45dfde8ef6 100644 --- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts +++ b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts @@ -2504,6 +2504,7 @@ export type Query = { * Either token or workspaceId must be specified, or both */ workspaceInvite?: Maybe; + workspacePricingPlans: Scalars['JSONObject']['output']; }; diff --git a/packages/server/modules/gatekeeper/clients/stripe.ts b/packages/server/modules/gatekeeper/clients/stripe.ts new file mode 100644 index 0000000000..2bd749cfd2 --- /dev/null +++ b/packages/server/modules/gatekeeper/clients/stripe.ts @@ -0,0 +1,150 @@ +/* eslint-disable camelcase */ +import { + CreateCheckoutSession, + GetSubscriptionData, + WorkspaceSubscription +} from '@/modules/gatekeeper/domain/billing' +import { + WorkspacePlanBillingIntervals, + WorkspacePricingPlans +} from '@/modules/gatekeeper/domain/workspacePricing' +import { Stripe } from 'stripe' + +type GetWorkspacePlanPrice = (args: { + workspacePlan: WorkspacePricingPlans + billingInterval: WorkspacePlanBillingIntervals +}) => string + +export const createCheckoutSessionFactory = + ({ + stripe, + frontendOrigin, + getWorkspacePlanPrice + }: { + stripe: Stripe + frontendOrigin: string + getWorkspacePlanPrice: GetWorkspacePlanPrice + }): CreateCheckoutSession => + async ({ + seatCount, + guestCount, + workspacePlan, + billingInterval, + workspaceSlug, + workspaceId + }) => { + //?settings=workspace/security& + const resultUrl = new URL( + `${frontendOrigin}/workspaces/${workspaceSlug}?workspace=${workspaceId}&settings=workspace/billing` + ) + + const price = getWorkspacePlanPrice({ billingInterval, workspacePlan }) + const costLineItems: Stripe.Checkout.SessionCreateParams.LineItem[] = [ + { price, quantity: seatCount } + ] + if (guestCount > 0) + costLineItems.push({ + price: getWorkspacePlanPrice({ + workspacePlan: 'guest', + billingInterval + }), + quantity: guestCount + }) + + const session = await stripe.checkout.sessions.create({ + mode: 'subscription', + + line_items: costLineItems, + + success_url: `${resultUrl.toString()}&payment_status=success&session_id={CHECKOUT_SESSION_ID}`, + + cancel_url: `${resultUrl.toString()}&payment_status=cancelled&session_id={CHECKOUT_SESSION_ID}` + }) + + if (!session.url) throw new Error('Failed to create an active checkout session') + return { + id: session.id, + url: session.url, + billingInterval, + workspacePlan, + workspaceId, + createdAt: new Date(), + updatedAt: new Date(), + paymentStatus: 'unpaid' + } + } + +export const getSubscriptionDataFactory = + ({ + stripe + }: // getWorkspacePlanPrice + { + stripe: Stripe + // getWorkspacePlanPrice: GetWorkspacePlanPrice + }): GetSubscriptionData => + async ({ subscriptionId }) => { + const stripeSubscription = await stripe.subscriptions.retrieve(subscriptionId) + + return { + customerId: + typeof stripeSubscription.customer === 'string' + ? stripeSubscription.customer + : stripeSubscription.customer.id, + subscriptionId, + products: stripeSubscription.items.data.map((subscriptionItem) => { + const productId = + typeof subscriptionItem.price.product === 'string' + ? subscriptionItem.price.product + : subscriptionItem.price.product.id + const quantity = subscriptionItem.quantity + if (!quantity) + throw new Error( + 'invalid subscription, we do not support products without quantities' + ) + return { + priceId: subscriptionItem.price.id, + productId, + quantity, + subscriptionItemId: subscriptionItem.id + } + }) + } + } + +// 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 + }) => { + const existingSubscriptionState = await getSubscriptionDataFactory({ stripe })({ + subscriptionId: workspaceSubscription.subscriptionData.subscriptionId + }) + const items: Stripe.SubscriptionUpdateParams.Item[] = [] + for (const product of workspaceSubscription.subscriptionData.products) { + const existingProduct = existingSubscriptionState.products.find( + (p) => p.productId === product.productId + ) + // we're adding a new product to the sub + if (!existingProduct) { + items.push({ quantity: product.quantity, price: product.priceId }) + // we're moving a product to a new price for ie upgrading to a yearly plan + } else if (existingProduct.priceId !== product.priceId) { + items.push({ quantity: product.quantity, price: product.priceId }) + items.push({ id: product.subscriptionItemId, deleted: true }) + } else { + items.push({ quantity: product.quantity, id: product.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' } + ) + } diff --git a/packages/server/modules/gatekeeper/domain/billing.ts b/packages/server/modules/gatekeeper/domain/billing.ts new file mode 100644 index 0000000000..0d3ab11cf4 --- /dev/null +++ b/packages/server/modules/gatekeeper/domain/billing.ts @@ -0,0 +1,145 @@ +import { + TrialWorkspacePlans, + PaidWorkspacePlans, + UnpaidWorkspacePlans, + WorkspacePlanBillingIntervals, + WorkspacePricingPlans +} from '@/modules/gatekeeper/domain/workspacePricing' +import { z } from 'zod' + +export type UnpaidWorkspacePlanStatuses = 'valid' + +export type PaidWorkspacePlanStatuses = + | UnpaidWorkspacePlanStatuses + // | 'paymentNeeded' // unsure if this is needed + | 'paymentFailed' + | 'cancelled' + +export type TrialWorkspacePlanStatuses = 'trial' + +type BaseWorkspacePlan = { + workspaceId: string +} + +export type PaidWorkspacePlan = BaseWorkspacePlan & { + name: PaidWorkspacePlans + status: PaidWorkspacePlanStatuses +} + +export type TrialWorkspacePlan = BaseWorkspacePlan & { + name: TrialWorkspacePlans + status: TrialWorkspacePlanStatuses +} + +export type UnpaidWorkspacePlan = BaseWorkspacePlan & { + name: UnpaidWorkspacePlans + status: UnpaidWorkspacePlanStatuses +} + +export type WorkspacePlan = PaidWorkspacePlan | TrialWorkspacePlan | UnpaidWorkspacePlan + +export type GetWorkspacePlan = (args: { + workspaceId: string +}) => Promise + +export type UpsertTrialWorkspacePlan = (args: { + workspacePlan: TrialWorkspacePlan +}) => Promise + +export type UpsertPaidWorkspacePlan = (args: { + workspacePlan: PaidWorkspacePlan +}) => Promise + +export type UpsertWorkspacePlan = (args: { + workspacePlan: WorkspacePlan +}) => Promise + +export type SessionInput = { + id: string +} + +export type SessionPaymentStatus = 'paid' | 'unpaid' + +export type CheckoutSession = SessionInput & { + url: string + workspaceId: string + workspacePlan: PaidWorkspacePlans + paymentStatus: SessionPaymentStatus + billingInterval: WorkspacePlanBillingIntervals + createdAt: Date + updatedAt: Date +} + +export type SaveCheckoutSession = (args: { + checkoutSession: CheckoutSession +}) => Promise + +export type GetCheckoutSession = (args: { + sessionId: string +}) => Promise + +export type DeleteCheckoutSession = (args: { + checkoutSessionId: string +}) => Promise + +export type GetWorkspaceCheckoutSession = (args: { + workspaceId: string +}) => Promise + +export type UpdateCheckoutSessionStatus = (args: { + sessionId: string + paymentStatus: SessionPaymentStatus +}) => Promise + +export type CreateCheckoutSession = (args: { + workspaceId: string + workspaceSlug: string + seatCount: number + guestCount: number + workspacePlan: PaidWorkspacePlans + billingInterval: WorkspacePlanBillingIntervals +}) => Promise + +export type WorkspaceSubscription = { + workspaceId: string + createdAt: Date + updatedAt: Date + currentBillingCycleEnd: Date + billingInterval: WorkspacePlanBillingIntervals + subscriptionData: SubscriptionData +} + +export const subscriptionData = z.object({ + subscriptionId: z.string().min(1), + customerId: z.string().min(1), + 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() +}) + +// this abstracts the stripe sub data +export type SubscriptionData = z.infer + +export type SaveWorkspaceSubscription = (args: { + workspaceSubscription: WorkspaceSubscription +}) => Promise + +export type GetSubscriptionData = (args: { + subscriptionId: string +}) => Promise + +export type GetWorkspacePlanPrice = (args: { + workspacePlan: WorkspacePricingPlans + billingInterval: WorkspacePlanBillingIntervals +}) => string + +export type ReconcileWorkspaceSubscription = (args: { + workspaceSubscription: WorkspaceSubscription + applyProrotation: boolean +}) => Promise diff --git a/packages/server/modules/gatekeeper/domain/types.ts b/packages/server/modules/gatekeeper/domain/types.ts index 0a425384a4..044c63c119 100644 --- a/packages/server/modules/gatekeeper/domain/types.ts +++ b/packages/server/modules/gatekeeper/domain/types.ts @@ -1,8 +1,12 @@ import { z } from 'zod' -const EnabledModules = z.object({ - workspaces: z.boolean() -}) +const EnabledModules = z + .object({ + workspaces: z.boolean(), + gatekeeper: z.boolean(), + billing: z.boolean() + }) + .partial() export type EnabledModules = z.infer diff --git a/packages/server/modules/gatekeeper/domain/workspacePricing.ts b/packages/server/modules/gatekeeper/domain/workspacePricing.ts new file mode 100644 index 0000000000..e552fe102c --- /dev/null +++ b/packages/server/modules/gatekeeper/domain/workspacePricing.ts @@ -0,0 +1,152 @@ +import { z } from 'zod' + +type Features = + | 'domainBasedSecurityPolicies' + | 'oidcSso' + | 'workspaceDataRegionSpecificity' + +type FeatureDetails = { + displayName: string + description?: string +} + +const features: Record = { + domainBasedSecurityPolicies: { + description: 'Email domain based security policies', + displayName: 'Domain security policies' + }, + oidcSso: { + displayName: 'Login / signup to the workspace with an OIDC provider' + }, + workspaceDataRegionSpecificity: { + displayName: 'Specify the geolocation, where the workspace project data is stored' + } +} as const + +type WorkspaceFeatures = Record + +type Limits = 'uploadSize' | 'automateMinutes' + +type LimitDetails = { + displayName: string + measurementUnit: string | null +} + +const limits: Record = { + automateMinutes: { + displayName: 'Automate minutes', + measurementUnit: 'minutes' + }, + uploadSize: { + displayName: 'Upload size limit', + measurementUnit: 'MB' + } +} + +export const workspacePricingPlanInformation = { features, limits } + +type WorkspaceLimits = Record + +type WorkspacePlanFeaturesAndLimits = WorkspaceFeatures & WorkspaceLimits + +const baseFeatures = { + domainBasedSecurityPolicies: true +} + +export const trialWorkspacePlans = z.literal('team') + +export type TrialWorkspacePlans = z.infer + +export const paidWorkspacePlans = z.union([ + trialWorkspacePlans, + z.literal('pro'), + z.literal('business') +]) + +export type PaidWorkspacePlans = z.infer + +// these are not publicly exposed for general use on billing enabled servers +export const unpaidWorkspacePlans = z.union([ + z.literal('unlimited'), + z.literal('academia') +]) + +export type UnpaidWorkspacePlans = z.infer + +export const workspacePlans = z.union([paidWorkspacePlans, unpaidWorkspacePlans]) + +// this includes the plans your workspace can be on +export type WorkspacePlans = z.infer + +// this includes the pricing plans a customer can sub to +export type WorkspacePricingPlans = PaidWorkspacePlans | 'guest' + +export const workspacePlanBillingIntervals = z.union([ + z.literal('monthly'), + z.literal('yearly') +]) +export type WorkspacePlanBillingIntervals = z.infer< + typeof workspacePlanBillingIntervals +> + +const team: WorkspacePlanFeaturesAndLimits = { + ...baseFeatures, + oidcSso: false, + workspaceDataRegionSpecificity: false, + automateMinutes: 300, + uploadSize: 500 +} + +const pro: WorkspacePlanFeaturesAndLimits = { + ...baseFeatures, + oidcSso: true, + workspaceDataRegionSpecificity: false, + automateMinutes: 900, + uploadSize: 1000 +} + +const business: WorkspacePlanFeaturesAndLimits = { + ...baseFeatures, + oidcSso: true, + workspaceDataRegionSpecificity: true, + automateMinutes: 900, + uploadSize: 1000 +} + +const unlimited: WorkspacePlanFeaturesAndLimits = { + ...baseFeatures, + oidcSso: true, + workspaceDataRegionSpecificity: true, + automateMinutes: null, + uploadSize: 1000 +} + +const academia: WorkspacePlanFeaturesAndLimits = { + ...baseFeatures, + oidcSso: true, + workspaceDataRegionSpecificity: false, + automateMinutes: null, + uploadSize: 100 +} + +const paidWorkspacePlanFeatures: Record< + PaidWorkspacePlans, + WorkspacePlanFeaturesAndLimits +> = { + team, + pro, + business +} + +export const unpaidWorkspacePlanFeatures: Record< + UnpaidWorkspacePlans, + WorkspacePlanFeaturesAndLimits +> = { + academia, + unlimited +} + +export const pricingTable = { + workspacePricingPlanInformation, + workspacePlanInformation: paidWorkspacePlanFeatures +} diff --git a/packages/server/modules/gatekeeper/errors/billing.ts b/packages/server/modules/gatekeeper/errors/billing.ts new file mode 100644 index 0000000000..e1e1ad6303 --- /dev/null +++ b/packages/server/modules/gatekeeper/errors/billing.ts @@ -0,0 +1,25 @@ +import { BaseError } from '@/modules/shared/errors' + +export class WorkspacePlanNotFoundError extends BaseError { + static defaultMessage = 'Workspace plan not found' + static code = 'WORKSPACE_PLAN_NOT_FOUND_ERROR' + static statusCode = 500 +} + +export class WorkspaceCheckoutSessionInProgressError extends BaseError { + static defaultMessage = 'Workspace already has a checkout session in progress' + static code = 'WORKSPACE_CHECKOUT_SESSION_IN_PROGRESS_ERROR' + static statusCode = 400 +} + +export class WorkspaceAlreadyPaidError extends BaseError { + static defaultMessage = 'Workspace is already on a paid plan' + static code = 'WORKSPACE_ALREADY_PAID_ERROR' + static statusCode = 400 +} + +export class CheckoutSessionNotFoundError extends BaseError { + static defaultMessage = 'Checkout session is not found' + static code = 'CHECKOUT_SESSION_NOT_FOUND' + static statusCode = 404 +} diff --git a/packages/server/modules/gatekeeper/graph/resolvers/index.ts b/packages/server/modules/gatekeeper/graph/resolvers/index.ts new file mode 100644 index 0000000000..670558403b --- /dev/null +++ b/packages/server/modules/gatekeeper/graph/resolvers/index.ts @@ -0,0 +1,15 @@ +import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' +import { Resolvers } from '@/modules/core/graph/generated/graphql' +import { pricingTable } from '@/modules/gatekeeper/domain/workspacePricing' + +const { FF_GATEKEEPER_MODULE_ENABLED } = getFeatureFlags() + +export = FF_GATEKEEPER_MODULE_ENABLED + ? ({ + Query: { + workspacePricingPlans: async () => { + return pricingTable + } + } + } as Resolvers) + : {} diff --git a/packages/server/modules/gatekeeper/index.ts b/packages/server/modules/gatekeeper/index.ts new file mode 100644 index 0000000000..5e37d93151 --- /dev/null +++ b/packages/server/modules/gatekeeper/index.ts @@ -0,0 +1,41 @@ +import { moduleLogger } from '@/logging/logging' +import { SpeckleModule } from '@/modules/shared/helpers/typeHelper' +import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' +import { validateModuleLicense } from '@/modules/gatekeeper/services/validateLicense' +import billingRouter from '@/modules/gatekeeper/rest/billing' + +const { FF_GATEKEEPER_MODULE_ENABLED, FF_BILLING_INTEGRATION_ENABLED } = + getFeatureFlags() + +const gatekeeperModule: SpeckleModule = { + async init(app, isInitial) { + if (!FF_GATEKEEPER_MODULE_ENABLED) return + + const isLicenseValid = await validateModuleLicense({ + requiredModules: ['gatekeeper'] + }) + if (!isLicenseValid) + throw new Error( + 'The gatekeeper module needs a valid license to run, contact Speckle to get one.' + ) + + moduleLogger.info('🗝️ Init gatekeeper module') + + if (isInitial) { + // TODO: need to subscribe to the workspaceCreated event and store the workspacePlan as a trial if billing enabled, else store as unlimited + if (FF_BILLING_INTEGRATION_ENABLED) { + app.use(billingRouter) + + const isLicenseValid = await validateModuleLicense({ + requiredModules: ['billing'] + }) + if (!isLicenseValid) + throw new Error( + 'The the billing module needs a valid license to run, contact Speckle to get one.' + ) + // TODO: create a cron job, that removes unused seats from the subscription at the beginning of each workspace plan's billing cycle + } + } + } +} +export = gatekeeperModule diff --git a/packages/server/modules/gatekeeper/migrations/20241018132400_workspace_checkout.ts b/packages/server/modules/gatekeeper/migrations/20241018132400_workspace_checkout.ts new file mode 100644 index 0000000000..da222e7bf8 --- /dev/null +++ b/packages/server/modules/gatekeeper/migrations/20241018132400_workspace_checkout.ts @@ -0,0 +1,41 @@ +import { Knex } from 'knex' + +export async function up(knex: Knex): Promise { + await knex.schema.createTable('workspace_plans', (table) => { + // im associating this to the workspace 1-1, i do not want a 1-many relationship possible + table.text('workspaceId').primary().references('id').inTable('workspaces') + table.text('name').notNullable() + table.text('status').notNullable() + }) + await knex.schema.createTable('workspace_checkout_sessions', (table) => { + // im associating this to the workspace 1-1, i do not want a 1-many relationship possible + table.text('workspaceId').primary().references('id').inTable('workspaces') + // this is not the primaryId, its the stripe provided checkout sessionId + // but we'll still need to index by it + table.text('id').notNullable().index() + table.text('url').notNullable() + table.text('workspacePlan').notNullable() + table.text('paymentStatus').notNullable() + table.text('billingInterval').notNullable() + table.timestamp('createdAt', { precision: 3, useTz: true }).notNullable() + table.timestamp('updatedAt', { precision: 3, useTz: true }).notNullable() + }) + + await knex.schema.createTable('workspace_subscriptions', (table) => { + table.text('workspaceId').primary().references('id').inTable('workspaces') + table.timestamp('createdAt', { precision: 3, useTz: true }).notNullable() + table.timestamp('updatedAt', { precision: 3, useTz: true }).notNullable() + table + .timestamp('currentBillingCycleEnd', { precision: 3, useTz: true }) + .notNullable() + + table.text('billingInterval').notNullable() + table.jsonb('subscriptionData').notNullable() + }) +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTable('workspace_plans') + await knex.schema.dropTable('workspace_checkout_sessions') + await knex.schema.dropTable('workspace_subscriptions') +} diff --git a/packages/server/modules/gatekeeper/repositories/billing.ts b/packages/server/modules/gatekeeper/repositories/billing.ts new file mode 100644 index 0000000000..93d7fa9bc1 --- /dev/null +++ b/packages/server/modules/gatekeeper/repositories/billing.ts @@ -0,0 +1,101 @@ +import { + CheckoutSession, + GetCheckoutSession, + GetWorkspacePlan, + SaveCheckoutSession, + UpdateCheckoutSessionStatus, + UpsertWorkspacePlan, + SaveWorkspaceSubscription, + WorkspaceSubscription, + WorkspacePlan, + UpsertPaidWorkspacePlan, + DeleteCheckoutSession, + GetWorkspaceCheckoutSession +} from '@/modules/gatekeeper/domain/billing' +import { Knex } from 'knex' + +const tables = { + workspacePlans: (db: Knex) => db('workspace_plans'), + workspaceCheckoutSessions: (db: Knex) => + db('workspace_checkout_sessions'), + workspaceSubscriptions: (db: Knex) => + db('workspace_subscriptions') +} + +export const getWorkspacePlanFactory = + ({ db }: { db: Knex }): GetWorkspacePlan => + async ({ workspaceId }) => { + const workspacePlan = await tables + .workspacePlans(db) + .select() + .where({ workspaceId }) + .first() + return workspacePlan ?? null + } + +const upsertWorkspacePlanFactory = + ({ db }: { db: Knex }): UpsertWorkspacePlan => + async ({ workspacePlan }) => { + await tables + .workspacePlans(db) + .insert(workspacePlan) + .onConflict('workspaceId') + .merge(['name', 'status']) + } + +// this is a typed rebrand of the generic workspace plan upsert +// this way TS guards the payment plan type validity +export const upsertPaidWorkspacePlanFactory = ({ + db +}: { + db: Knex +}): UpsertPaidWorkspacePlan => upsertWorkspacePlanFactory({ db }) + +export const saveCheckoutSessionFactory = + ({ db }: { db: Knex }): SaveCheckoutSession => + async ({ checkoutSession }) => { + await tables.workspaceCheckoutSessions(db).insert(checkoutSession) + } + +export const deleteCheckoutSessionFactory = + ({ db }: { db: Knex }): DeleteCheckoutSession => + async ({ checkoutSessionId }) => { + await tables.workspaceCheckoutSessions(db).delete().where({ id: checkoutSessionId }) + } + +export const getCheckoutSessionFactory = + ({ db }: { db: Knex }): GetCheckoutSession => + async ({ sessionId }) => { + const checkoutSession = await tables + .workspaceCheckoutSessions(db) + .select() + .where({ id: sessionId }) + .first() + return checkoutSession || null + } + +export const getWorkspaceCheckoutSessionFactory = + ({ db }: { db: Knex }): GetWorkspaceCheckoutSession => + async ({ workspaceId }) => { + const checkoutSession = await tables + .workspaceCheckoutSessions(db) + .select() + .where({ workspaceId }) + .first() + return checkoutSession || null + } + +export const updateCheckoutSessionStatusFactory = + ({ db }: { db: Knex }): UpdateCheckoutSessionStatus => + async ({ sessionId, paymentStatus }) => { + await tables + .workspaceCheckoutSessions(db) + .where({ id: sessionId }) + .update({ paymentStatus, updatedAt: new Date() }) + } + +export const saveWorkspaceSubscriptionFactory = + ({ db }: { db: Knex }): SaveWorkspaceSubscription => + async ({ workspaceSubscription }) => { + await tables.workspaceSubscriptions(db).insert(workspaceSubscription) + } diff --git a/packages/server/modules/gatekeeper/rest/billing.ts b/packages/server/modules/gatekeeper/rest/billing.ts new file mode 100644 index 0000000000..da2a815f9a --- /dev/null +++ b/packages/server/modules/gatekeeper/rest/billing.ts @@ -0,0 +1,224 @@ +import { Router } from 'express' +import { validateRequest } from 'zod-express' +import { z } from 'zod' +import { authorizeResolver } from '@/modules/shared' +import { ensureError, Roles } from '@speckle/shared' +import { Stripe } from 'stripe' +import { + getFrontendOrigin, + getStringFromEnv, + getStripeApiKey, + getStripeEndpointSigningKey +} from '@/modules/shared/helpers/envHelper' +import { + WorkspacePlanBillingIntervals, + paidWorkspacePlans, + WorkspacePricingPlans, + workspacePlanBillingIntervals +} from '@/modules/gatekeeper/domain/workspacePricing' +import { + countWorkspaceRoleWithOptionalProjectRoleFactory, + getWorkspaceFactory +} from '@/modules/workspaces/repositories/workspaces' +import { db } from '@/db/knex' +import { + completeCheckoutSessionFactory, + startCheckoutSessionFactory +} from '@/modules/gatekeeper/services/checkout' +import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace' +import { + createCheckoutSessionFactory, + getSubscriptionDataFactory +} from '@/modules/gatekeeper/clients/stripe' +import { + deleteCheckoutSessionFactory, + getCheckoutSessionFactory, + getWorkspaceCheckoutSessionFactory, + getWorkspacePlanFactory, + saveCheckoutSessionFactory, + saveWorkspaceSubscriptionFactory, + updateCheckoutSessionStatusFactory, + upsertPaidWorkspacePlanFactory +} from '@/modules/gatekeeper/repositories/billing' +import { GetWorkspacePlanPrice } from '@/modules/gatekeeper/domain/billing' +import { WorkspaceAlreadyPaidError } from '@/modules/gatekeeper/errors/billing' +import { withTransaction } from '@/modules/shared/helpers/dbHelper' + +const router = Router() + +export default router + +const stripe = new Stripe(getStripeApiKey(), { typescript: true }) + +const workspacePlanPrices = (): Record< + WorkspacePricingPlans, + Record & { productId: string } +> => ({ + guest: { + productId: getStringFromEnv('WORKSPACE_GUEST_SEAT_STRIPE_PRODUCT_ID'), + monthly: getStringFromEnv('WORKSPACE_MONTHLY_GUEST_SEAT_STRIPE_PRICE_ID'), + yearly: getStringFromEnv('WORKSPACE_YEARLY_GUEST_SEAT_STRIPE_PRICE_ID') + }, + team: { + productId: getStringFromEnv('WORKSPACE_TEAM_SEAT_STRIPE_PRODUCT_ID'), + monthly: getStringFromEnv('WORKSPACE_MONTHLY_TEAM_SEAT_STRIPE_PRICE_ID'), + yearly: getStringFromEnv('WORKSPACE_YEARLY_TEAM_SEAT_STRIPE_PRICE_ID') + }, + pro: { + productId: getStringFromEnv('WORKSPACE_PRO_SEAT_STRIPE_PRODUCT_ID'), + monthly: getStringFromEnv('WORKSPACE_MONTHLY_PRO_SEAT_STRIPE_PRICE_ID'), + yearly: getStringFromEnv('WORKSPACE_YEARLY_PRO_SEAT_STRIPE_PRICE_ID') + }, + business: { + productId: getStringFromEnv('WORKSPACE_BUSINESS_SEAT_STRIPE_PRODUCT_ID'), + monthly: getStringFromEnv('WORKSPACE_MONTHLY_BUSINESS_SEAT_STRIPE_PRICE_ID'), + yearly: getStringFromEnv('WORKSPACE_YEARLY_BUSINESS_SEAT_STRIPE_PRICE_ID') + } +}) + +const getWorkspacePlanPrice: GetWorkspacePlanPrice = ({ + workspacePlan, + billingInterval +}) => workspacePlanPrices()[workspacePlan][billingInterval] + +// this prob needs to be turned into a GQL resolver for better frontend integration for errors +router.get( + '/api/v1/billing/workspaces/:workspaceId/checkout-session/:workspacePlan/:billingInterval', + validateRequest({ + params: z.object({ + workspaceId: z.string().min(1), + workspacePlan: paidWorkspacePlans, + billingInterval: workspacePlanBillingIntervals + }) + }), + async (req) => { + const { workspaceId, workspacePlan, billingInterval } = req.params + const workspace = await getWorkspaceFactory({ db })({ workspaceId }) + + if (!workspace) throw new WorkspaceNotFoundError() + + await authorizeResolver( + req.context.userId, + workspaceId, + Roles.Workspace.Admin, + req.context.resourceAccessRules + ) + + const createCheckoutSession = createCheckoutSessionFactory({ + stripe, + frontendOrigin: getFrontendOrigin(), + getWorkspacePlanPrice + }) + + const countRole = countWorkspaceRoleWithOptionalProjectRoleFactory({ db }) + + const session = await startCheckoutSessionFactory({ + getWorkspaceCheckoutSession: getWorkspaceCheckoutSessionFactory({ db }), + getWorkspacePlan: getWorkspacePlanFactory({ db }), + countRole, + createCheckoutSession, + saveCheckoutSession: saveCheckoutSessionFactory({ db }) + })({ workspacePlan, workspaceId, workspaceSlug: workspace.slug, billingInterval }) + + req.res?.redirect(session.url) + } +) + +router.post('/api/v1/billing/webhooks', async (req, res) => { + const endpointSecret = getStripeEndpointSigningKey() + const sig = req.headers['stripe-signature'] + if (!sig) { + res.status(400).send('Missing payload signature') + return + } + + let event: Stripe.Event + + try { + event = stripe.webhooks.constructEvent( + // yes, the express json middleware auto parses the payload and stri need it in a string + req.body, + sig, + endpointSecret + ) + } catch (err) { + res.status(400).send(`Webhook Error: ${ensureError(err).message}`) + return + } + + switch (event.type) { + case 'checkout.session.async_payment_failed': + // TODO: need to alert the user and delete the session ? + break + case 'checkout.session.async_payment_succeeded': + case 'checkout.session.completed': + const session = event.data.object + + if (!session.subscription) + return res.status(400).send('We only support subscription type checkouts') + + if (session.payment_status === 'paid') { + // If the workspace is already on a paid plan, we made a bo bo. + // existing subs should be updated via the api, not pushed through the checkout sess again + // the start checkout endpoint should guard this! + // get checkout session from the DB, if not found CONTACT SUPPORT!!! + // if the session is already paid, means, we've already settled this checkout, and this is a webhook recall + // set checkout state to paid + // go ahead and provision the plan + // store customer id and subscription Id associated to the workspace plan + + const subscriptionId = + typeof session.subscription === 'string' + ? session.subscription + : session.subscription.id + + // this must use a transaction + + const trx = await db.transaction() + + const completeCheckout = completeCheckoutSessionFactory({ + getCheckoutSession: getCheckoutSessionFactory({ db: trx }), + updateCheckoutSessionStatus: updateCheckoutSessionStatusFactory({ db: trx }), + upsertPaidWorkspacePlan: upsertPaidWorkspacePlanFactory({ db: trx }), + saveWorkspaceSubscription: saveWorkspaceSubscriptionFactory({ db: trx }), + getSubscriptionData: getSubscriptionDataFactory({ + stripe + }) + }) + + try { + await withTransaction( + completeCheckout({ + sessionId: session.id, + subscriptionId + }), + trx + ) + } catch (err) { + if (err instanceof WorkspaceAlreadyPaidError) { + // ignore the request, this is prob a replay from stripe + } else { + throw err + } + } + } + break + + case 'checkout.session.expired': + // delete the checkout session from the DB + await deleteCheckoutSessionFactory({ db })({ + checkoutSessionId: event.data.object.id + }) + break + + default: + break + } + + res.status(200).send('ok') +}) + +// prob needed when the checkout is cancelled +router.delete( + '/api/v1/billing/workspaces/:workspaceSlug/checkout-session/:workspacePlan' +) diff --git a/packages/server/modules/gatekeeper/services/checkout.ts b/packages/server/modules/gatekeeper/services/checkout.ts new file mode 100644 index 0000000000..b9a9e76112 --- /dev/null +++ b/packages/server/modules/gatekeeper/services/checkout.ts @@ -0,0 +1,172 @@ +import { + CheckoutSession, + CreateCheckoutSession, + GetCheckoutSession, + GetWorkspacePlan, + SaveCheckoutSession, + UpdateCheckoutSessionStatus, + SaveWorkspaceSubscription, + UpsertPaidWorkspacePlan, + GetSubscriptionData, + GetWorkspaceCheckoutSession +} from '@/modules/gatekeeper/domain/billing' +import { + PaidWorkspacePlans, + WorkspacePlanBillingIntervals +} from '@/modules/gatekeeper/domain/workspacePricing' +import { + CheckoutSessionNotFoundError, + WorkspaceAlreadyPaidError, + WorkspaceCheckoutSessionInProgressError +} from '@/modules/gatekeeper/errors/billing' +import { CountWorkspaceRoleWithOptionalProjectRole } from '@/modules/workspaces/domain/operations' +import { Roles, throwUncoveredError } from '@speckle/shared' + +export const startCheckoutSessionFactory = + ({ + getWorkspaceCheckoutSession, + getWorkspacePlan, + countRole, + createCheckoutSession, + saveCheckoutSession + }: { + getWorkspaceCheckoutSession: GetWorkspaceCheckoutSession + getWorkspacePlan: GetWorkspacePlan + countRole: CountWorkspaceRoleWithOptionalProjectRole + createCheckoutSession: CreateCheckoutSession + saveCheckoutSession: SaveCheckoutSession + }) => + async ({ + workspaceId, + workspaceSlug, + workspacePlan, + billingInterval + }: { + workspaceId: string + workspaceSlug: string + workspacePlan: PaidWorkspacePlans + billingInterval: WorkspacePlanBillingIntervals + }): 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 cancelled status is not something we need a checkout for + // we already have their credit card info + case 'valid': + case 'paymentFailed': + throw new WorkspaceAlreadyPaidError() + case 'cancelled': + // maybe, we can reactivate cancelled 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': + // 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) 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 + }) + + await saveCheckoutSession({ checkoutSession }) + return checkoutSession + } + +export const completeCheckoutSessionFactory = + ({ + getCheckoutSession, + updateCheckoutSessionStatus, + saveWorkspaceSubscription, + upsertPaidWorkspacePlan, + getSubscriptionData + }: { + getCheckoutSession: GetCheckoutSession + updateCheckoutSessionStatus: UpdateCheckoutSessionStatus + saveWorkspaceSubscription: SaveWorkspaceSubscription + upsertPaidWorkspacePlan: UpsertPaidWorkspacePlan + getSubscriptionData: GetSubscriptionData + }) => + /** + * Complete a paid checkout session + */ + async ({ + sessionId, + subscriptionId + }: { + sessionId: string + subscriptionId: string + }): Promise => { + const checkoutSession = await getCheckoutSession({ sessionId }) + if (!checkoutSession) throw new CheckoutSessionNotFoundError() + + switch (checkoutSession.paymentStatus) { + case 'paid': + // if the session is already paid, we do not need to provision anything + throw new WorkspaceAlreadyPaidError() + case 'unpaid': + break + default: + throwUncoveredError(checkoutSession.paymentStatus) + } + // TODO: make sure, the subscription data price plan matches the checkout session workspacePlan + + await updateCheckoutSessionStatus({ sessionId, paymentStatus: 'paid' }) + // a plan determines the workspace feature set + await upsertPaidWorkspacePlan({ + workspacePlan: { + workspaceId: checkoutSession.workspaceId, + name: checkoutSession.workspacePlan, + status: 'valid' + } + }) + const subscriptionData = await getSubscriptionData({ + subscriptionId + }) + const currentBillingCycleEnd = new Date() + switch (checkoutSession.billingInterval) { + case 'monthly': + currentBillingCycleEnd.setMonth(currentBillingCycleEnd.getMonth() + 1) + break + case 'yearly': + currentBillingCycleEnd.setMonth(currentBillingCycleEnd.getMonth() + 12) + break + + default: + throwUncoveredError(checkoutSession.billingInterval) + } + + const workspaceSubscription = { + createdAt: new Date(), + updatedAt: new Date(), + currentBillingCycleEnd, + workspaceId: checkoutSession.workspaceId, + billingInterval: checkoutSession.billingInterval, + subscriptionData + } + + await saveWorkspaceSubscription({ + workspaceSubscription + }) + } diff --git a/packages/server/modules/gatekeeper/tests/intergration/billingRepositories.spec.ts b/packages/server/modules/gatekeeper/tests/intergration/billingRepositories.spec.ts new file mode 100644 index 0000000000..7d3d0c366b --- /dev/null +++ b/packages/server/modules/gatekeeper/tests/intergration/billingRepositories.spec.ts @@ -0,0 +1,218 @@ +import db from '@/db/knex' +import { + deleteCheckoutSessionFactory, + getCheckoutSessionFactory, + getWorkspaceCheckoutSessionFactory, + getWorkspacePlanFactory, + saveCheckoutSessionFactory, + saveWorkspaceSubscriptionFactory, + updateCheckoutSessionStatusFactory, + upsertPaidWorkspacePlanFactory +} from '@/modules/gatekeeper/repositories/billing' +import { upsertWorkspaceFactory } from '@/modules/workspaces/repositories/workspaces' +import { createAndStoreTestWorkspaceFactory } from '@/test/speckle-helpers/workspaces' +import { expect } from 'chai' +import cryptoRandomString from 'crypto-random-string' + +const upsertWorkspace = upsertWorkspaceFactory({ db }) +const createAndStoreTestWorkspace = createAndStoreTestWorkspaceFactory({ + upsertWorkspace +}) +const getWorkspacePlan = getWorkspacePlanFactory({ db }) +const upsertPaidWorkspacePlan = upsertPaidWorkspacePlanFactory({ db }) +const saveCheckoutSession = saveCheckoutSessionFactory({ db }) +const deleteCheckoutSession = deleteCheckoutSessionFactory({ db }) +const getCheckoutSession = getCheckoutSessionFactory({ db }) +const getWorkspaceCheckoutSession = getWorkspaceCheckoutSessionFactory({ db }) +const updateCheckoutSessionStatus = updateCheckoutSessionStatusFactory({ db }) +const saveWorkspaceSubscription = saveWorkspaceSubscriptionFactory({ db }) + +describe('billing repositories @gatekeeper', () => { + describe('workspacePlans', () => { + describe('upsertPaidWorkspacePlanFactory creates a function, that', () => { + it('creates a workspacePlan if it does not exist', async () => { + const workspace = await createAndStoreTestWorkspace() + const workspaceId = workspace.id + let storedWorkspacePlan = await getWorkspacePlan({ workspaceId }) + expect(storedWorkspacePlan).to.be.null + const workspacePlan = { + name: 'business', + status: 'paymentFailed', + workspaceId + } as const + await upsertPaidWorkspacePlan({ + workspacePlan + }) + + storedWorkspacePlan = await getWorkspacePlan({ workspaceId }) + expect(storedWorkspacePlan).deep.equal(workspacePlan) + }) + it('updates a workspacePlan name and status if a plan exists', async () => { + const workspace = await createAndStoreTestWorkspace() + const workspaceId = workspace.id + const workspacePlan = { + name: 'business', + status: 'paymentFailed', + workspaceId + } as const + await upsertPaidWorkspacePlan({ + workspacePlan + }) + + let storedWorkspacePlan = await getWorkspacePlan({ workspaceId }) + expect(storedWorkspacePlan).deep.equal(workspacePlan) + + const planUpdate = { ...workspacePlan, status: 'valid' } as const + await upsertPaidWorkspacePlan({ workspacePlan: planUpdate }) + + storedWorkspacePlan = await getWorkspacePlan({ workspaceId }) + expect(storedWorkspacePlan).deep.equal(planUpdate) + }) + }) + }) + describe('checkoutSessions', () => { + describe('saveCheckoutSessionFactory creates a function that,', () => { + it('stores a checkout session', async () => { + const workspace = await createAndStoreTestWorkspace() + const workspaceId = workspace.id + let storedSession = await getWorkspaceCheckoutSession({ workspaceId }) + expect(storedSession).to.be.null + const checkoutSession = { + id: cryptoRandomString({ length: 10 }), + billingInterval: 'monthly', + createdAt: new Date(), + paymentStatus: 'unpaid', + updatedAt: new Date(), + url: 'https://example.com', + workspaceId, + workspacePlan: 'business' + } as const + + await saveCheckoutSession({ + checkoutSession + }) + + storedSession = await getCheckoutSession({ sessionId: checkoutSession.id }) + expect(storedSession).deep.equal(checkoutSession) + }) + }) + describe('deleteCheckoutSessionFactory creates a function, that', () => { + it('deletes a checkout session', async () => { + const workspace = await createAndStoreTestWorkspace() + const workspaceId = workspace.id + const checkoutSession = { + id: cryptoRandomString({ length: 10 }), + billingInterval: 'monthly', + createdAt: new Date(), + paymentStatus: 'unpaid', + updatedAt: new Date(), + url: 'https://example.com', + workspaceId, + workspacePlan: 'business' + } as const + + await saveCheckoutSession({ + checkoutSession + }) + + let storedSession = await getCheckoutSession({ sessionId: checkoutSession.id }) + expect(storedSession).deep.equal(checkoutSession) + await deleteCheckoutSession({ checkoutSessionId: checkoutSession.id }) + + storedSession = await getCheckoutSession({ sessionId: checkoutSession.id }) + expect(storedSession).to.be.null + }) + it('does not fail if the checkout session is not found', async () => { + await deleteCheckoutSession({ + checkoutSessionId: cryptoRandomString({ length: 10 }) + }) + }) + }) + describe('updateCheckoutSessionFactory creates a function, that', () => { + it('updates the session paymentStatus and updatedAt', async () => { + const workspace = await createAndStoreTestWorkspace() + const workspaceId = workspace.id + const checkoutSession = { + id: cryptoRandomString({ length: 10 }), + billingInterval: 'monthly', + createdAt: new Date(), + paymentStatus: 'unpaid', + updatedAt: new Date(), + url: 'https://example.com', + workspaceId, + workspacePlan: 'business' + } as const + + await saveCheckoutSession({ + checkoutSession + }) + + let storedSession = await getCheckoutSession({ + sessionId: checkoutSession.id + }) + expect(storedSession).deep.equal(checkoutSession) + + await updateCheckoutSessionStatus({ + sessionId: checkoutSession.id, + paymentStatus: 'paid' + }) + + storedSession = await getCheckoutSession({ + sessionId: checkoutSession.id + }) + expect(storedSession?.paymentStatus).to.equal('paid') + }) + }) + describe('getWorkspaceCheckoutSessionFactory creates a function, that', () => { + it('returns null for workspaces without checkoutSessions', async () => { + const workspace = await createAndStoreTestWorkspace() + const workspaceId = workspace.id + const storedSession = await getWorkspaceCheckoutSession({ workspaceId }) + expect(storedSession).to.be.null + }) + it('gets the checkout session for the workspace', async () => { + const workspace = await createAndStoreTestWorkspace() + const workspaceId = workspace.id + const checkoutSession = { + id: cryptoRandomString({ length: 10 }), + billingInterval: 'monthly', + createdAt: new Date(), + paymentStatus: 'unpaid', + updatedAt: new Date(), + url: 'https://example.com', + workspaceId, + workspacePlan: 'business' + } as const + + await saveCheckoutSession({ + checkoutSession + }) + + const storedSession = await getWorkspaceCheckoutSession({ workspaceId }) + expect(storedSession).deep.equal(checkoutSession) + }) + }) + }) + describe('workspaceSubscriptions', () => { + describe('saveWorkspaceSubscription creates a function, that', () => { + it('saves the subscription', async () => { + const workspace = await createAndStoreTestWorkspace() + const workspaceId = workspace.id + await saveWorkspaceSubscription({ + workspaceSubscription: { + billingInterval: 'monthly', + createdAt: new Date(), + updatedAt: new Date(), + currentBillingCycleEnd: new Date(), + subscriptionData: { + customerId: cryptoRandomString({ length: 10 }), + products: [], + subscriptionId: cryptoRandomString({ length: 10 }) + }, + workspaceId + } + }) + }) + }) + }) +}) diff --git a/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts b/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts new file mode 100644 index 0000000000..dc437de488 --- /dev/null +++ b/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts @@ -0,0 +1,326 @@ +import { + CheckoutSessionNotFoundError, + WorkspaceAlreadyPaidError, + WorkspaceCheckoutSessionInProgressError +} from '@/modules/gatekeeper/errors/billing' +import { + completeCheckoutSessionFactory, + startCheckoutSessionFactory +} from '@/modules/gatekeeper/services/checkout' +import { expectToThrow } from '@/test/assertionHelper' +import { expect } from 'chai' +import cryptoRandomString from 'crypto-random-string' +import { + CheckoutSession, + PaidWorkspacePlan, + SubscriptionData, + WorkspaceSubscription +} from '@/modules/gatekeeper/domain/billing' +import { + PaidWorkspacePlans, + WorkspacePlanBillingIntervals +} from '@/modules/gatekeeper/domain/workspacePricing' + +describe('checkout @gatekeeper', () => { + describe('startCheckoutSessionFactory 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({ + getWorkspacePlan: async () => ({ + name: 'pro', + status: 'valid', + workspaceId + }), + getWorkspaceCheckoutSession: () => { + expect.fail() + }, + countRole: () => { + expect.fail() + }, + createCheckoutSession: () => { + expect.fail() + }, + saveCheckoutSession: () => { + expect.fail() + } + })({ + workspaceId, + billingInterval: 'monthly', + workspacePlan: 'business', + workspaceSlug: cryptoRandomString({ length: 10 }) + }) + ) + expect(err.message).to.be.equal(new WorkspaceAlreadyPaidError().message) + }) + 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: 'pro', + status: 'paymentFailed', + workspaceId + }), + getWorkspaceCheckoutSession: () => { + expect.fail() + }, + countRole: () => { + expect.fail() + }, + createCheckoutSession: () => { + expect.fail() + }, + saveCheckoutSession: () => { + expect.fail() + } + })({ + workspaceId, + billingInterval: 'monthly', + workspacePlan: 'business', + workspaceSlug: cryptoRandomString({ length: 10 }) + }) + ) + 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: 'team', + status: 'trial', + workspaceId + }), + getWorkspaceCheckoutSession: async () => ({ + billingInterval: 'monthly', + id: cryptoRandomString({ length: 10 }), + paymentStatus: 'unpaid', + url: '', + workspaceId, + workspacePlan: 'business', + createdAt: new Date(), + updatedAt: new Date() + }), + countRole: () => { + expect.fail() + }, + createCheckoutSession: () => { + expect.fail() + }, + saveCheckoutSession: () => { + expect.fail() + } + })({ + workspaceId, + billingInterval: 'monthly', + workspacePlan: 'business', + workspaceSlug: cryptoRandomString({ length: 10 }) + }) + ) + expect(err.message).to.be.equal( + new WorkspaceCheckoutSessionInProgressError().message + ) + }) + it('creates and stores a checkout for workspaces that are not on a plan', 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 () => null, + getWorkspaceCheckoutSession: async () => null, + countRole: async () => 1, + createCheckoutSession: async () => checkoutSession, + saveCheckoutSession: async ({ checkoutSession }) => { + storedCheckoutSession = checkoutSession + } + })({ + workspaceId, + billingInterval, + workspacePlan, + workspaceSlug: cryptoRandomString({ length: 10 }) + }) + expect(checkoutSession).deep.equal(storedCheckoutSession) + expect(checkoutSession).deep.equal(createdCheckoutSession) + }) + it('creates and stores a checkout for TRIAL and CANCELLED 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 () => null, + getWorkspaceCheckoutSession: async () => null, + countRole: async () => 1, + createCheckoutSession: async () => checkoutSession, + saveCheckoutSession: async ({ checkoutSession }) => { + storedCheckoutSession = checkoutSession + } + })({ + workspaceId, + billingInterval, + workspacePlan, + workspaceSlug: cryptoRandomString({ length: 10 }) + }) + expect(checkoutSession).deep.equal(storedCheckoutSession) + 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 }) + const subscriptionId = cryptoRandomString({ length: 10 }) + + const err = await expectToThrow(async () => { + await completeCheckoutSessionFactory({ + getCheckoutSession: async () => null, + updateCheckoutSessionStatus: async () => { + expect.fail() + }, + upsertPaidWorkspacePlan: async () => { + expect.fail() + }, + getSubscriptionData: async () => { + expect.fail() + }, + saveWorkspaceSubscription: 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 err = await expectToThrow(async () => { + await completeCheckoutSessionFactory({ + getCheckoutSession: async () => ({ + billingInterval: 'monthly', + id: sessionId, + paymentStatus: 'paid', + url: 'https://example.com', + workspaceId: cryptoRandomString({ length: 10 }), + workspacePlan: 'business', + createdAt: new Date(), + updatedAt: new Date() + }), + updateCheckoutSessionStatus: async () => { + expect.fail() + }, + upsertPaidWorkspacePlan: async () => { + expect.fail() + }, + getSubscriptionData: async () => { + expect.fail() + }, + saveWorkspaceSubscription: 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 storedCheckoutSession: CheckoutSession = { + billingInterval, + id: sessionId, + paymentStatus: 'unpaid', + url: 'https://example.com', + workspaceId, + workspacePlan: 'business', + 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 }) + } + ] + } + + let storedWorkspaceSubscriptionData: WorkspaceSubscription | undefined = + undefined + + await completeCheckoutSessionFactory({ + getCheckoutSession: async () => storedCheckoutSession, + updateCheckoutSessionStatus: async ({ paymentStatus }) => { + storedCheckoutSession.paymentStatus = paymentStatus + }, + upsertPaidWorkspacePlan: async ({ workspacePlan }) => { + storedWorkspacePlan = workspacePlan + }, + getSubscriptionData: async () => subscriptionData, + saveWorkspaceSubscription: async ({ workspaceSubscription }) => { + storedWorkspaceSubscriptionData = workspaceSubscription + } + })({ sessionId, subscriptionId }) + + expect(storedCheckoutSession.paymentStatus).to.equal('paid') + expect(storedWorkspacePlan).to.deep.equal({ + workspaceId, + name: storedCheckoutSession.workspacePlan, + status: 'valid' + }) + expect(storedWorkspaceSubscriptionData!.billingInterval).to.equal( + storedCheckoutSession.billingInterval + ) + + expect(storedWorkspaceSubscriptionData!.subscriptionData).to.equal( + subscriptionData + ) + 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) + }) + }) + }) +}) diff --git a/packages/server/modules/gatekeeper/tests/validateLicense.spec.ts b/packages/server/modules/gatekeeper/tests/unit/validateLicense.spec.ts similarity index 99% rename from packages/server/modules/gatekeeper/tests/validateLicense.spec.ts rename to packages/server/modules/gatekeeper/tests/unit/validateLicense.spec.ts index d0dd49ca88..5d22a8f587 100644 --- a/packages/server/modules/gatekeeper/tests/validateLicense.spec.ts +++ b/packages/server/modules/gatekeeper/tests/unit/validateLicense.spec.ts @@ -123,7 +123,7 @@ describe('validateLicense @gatekeeper', () => { licenseToken, canonicalUrl, publicKey, - requiredModules: ['workspaces'] + requiredModules: ['workspaces', 'gatekeeper'] }) expect(result).to.be.false diff --git a/packages/server/modules/gendo/index.ts b/packages/server/modules/gendo/index.ts index 5f2a75a03f..5ba157db96 100644 --- a/packages/server/modules/gendo/index.ts +++ b/packages/server/modules/gendo/index.ts @@ -1,15 +1,20 @@ import { SpeckleModule } from '@/modules/shared/helpers/typeHelper' import { moduleLogger } from '@/logging/logging' import { corsMiddleware } from '@/modules/core/configs/cors' -import { getGendoAIResponseKey } from '@/modules/shared/helpers/envHelper' +import { + getGendoAIResponseKey, + getFeatureFlags +} from '@/modules/shared/helpers/envHelper' import { updateGendoAIRenderRequest } from '@/modules/gendo/services' -const responseToken = getGendoAIResponseKey() +const { FF_GENDOAI_MODULE_ENABLED } = getFeatureFlags() export = { async init(app) { + if (!FF_GENDOAI_MODULE_ENABLED) return moduleLogger.info('🪞 Init Gendo AI render module') + const responseToken = getGendoAIResponseKey() // Gendo api calls back in here with the result. app.options('/api/thirdparty/gendo', corsMiddleware()) app.post('/api/thirdparty/gendo', corsMiddleware(), async (req, res) => { diff --git a/packages/server/modules/index.ts b/packages/server/modules/index.ts index c8f323ed94..75c18d99d3 100644 --- a/packages/server/modules/index.ts +++ b/packages/server/modules/index.ts @@ -57,7 +57,8 @@ const getEnabledModuleNames = () => { const { FF_AUTOMATE_MODULE_ENABLED, FF_GENDOAI_MODULE_ENABLED, - FF_WORKSPACES_MODULE_ENABLED + FF_WORKSPACES_MODULE_ENABLED, + FF_GATEKEEPER_MODULE_ENABLED } = getFeatureFlags() const moduleNames = [ 'accessrequests', @@ -82,6 +83,7 @@ const getEnabledModuleNames = () => { if (FF_AUTOMATE_MODULE_ENABLED) moduleNames.push('automate') if (FF_GENDOAI_MODULE_ENABLED) moduleNames.push('gendo') if (FF_WORKSPACES_MODULE_ENABLED) moduleNames.push('workspaces') + if (FF_GATEKEEPER_MODULE_ENABLED) moduleNames.push('gatekeeper') return moduleNames } diff --git a/packages/server/modules/shared/helpers/envHelper.ts b/packages/server/modules/shared/helpers/envHelper.ts index ebf2204cdc..45ec78aefe 100644 --- a/packages/server/modules/shared/helpers/envHelper.ts +++ b/packages/server/modules/shared/helpers/envHelper.ts @@ -48,7 +48,15 @@ export function getIntFromEnv(envVarKey: string, aDefault = '0'): number { } export function getBooleanFromEnv(envVarKey: string, aDefault = false): boolean { - return ['1', 'true'].includes(process.env[envVarKey] || aDefault.toString()) + return ['1', 'true', true].includes(process.env[envVarKey] || aDefault.toString()) +} + +export function getStringFromEnv(envVarKey: string): string { + const envVar = process.env[envVarKey] + if (!envVar) { + throw new MisconfiguredEnvironmentError(`${envVarKey} env var not configured`) + } + return envVar } /** @@ -63,97 +71,47 @@ export function enableNewFrontendMessaging() { } export function getRedisUrl() { - if (!process.env.REDIS_URL) { - throw new MisconfiguredEnvironmentError('REDIS_URL env var not configured') - } - - return process.env.REDIS_URL + return getStringFromEnv('REDIS_URL') } export function getOidcDiscoveryUrl() { - if (!process.env.OIDC_DISCOVERY_URL) { - throw new MisconfiguredEnvironmentError('OIDC_DISCOVERY_URL env var not configured') - } - - return process.env.OIDC_DISCOVERY_URL + return getStringFromEnv('OIDC_DISCOVERY_URL') } export function getOidcClientId() { - if (!process.env.OIDC_CLIENT_ID) { - throw new MisconfiguredEnvironmentError('OIDC_CLIENT_ID env var not configured') - } - - return process.env.OIDC_CLIENT_ID + return getStringFromEnv('OIDC_CLIENT_ID') } export function getOidcClientSecret() { - if (!process.env.OIDC_CLIENT_SECRET) { - throw new MisconfiguredEnvironmentError('OIDC_CLIENT_SECRET env var not configured') - } - - return process.env.OIDC_CLIENT_SECRET + return getStringFromEnv('OIDC_CLIENT_SECRET') } export function getOidcName() { - if (!process.env.OIDC_NAME) { - throw new MisconfiguredEnvironmentError('OIDC_NAME env var not configured') - } - - return process.env.OIDC_NAME + return getStringFromEnv('OIDC_NAME') } export function getGoogleClientId() { - if (!process.env.GOOGLE_CLIENT_ID) { - throw new MisconfiguredEnvironmentError('GOOGLE_CLIENT_ID env var not configured') - } - - return process.env.GOOGLE_CLIENT_ID + return getStringFromEnv('GOOGLE_CLIENT_ID') } export function getGoogleClientSecret() { - if (!process.env.GOOGLE_CLIENT_SECRET) { - throw new MisconfiguredEnvironmentError( - 'GOOGLE_CLIENT_SECRET env var not configured' - ) - } - - return process.env.GOOGLE_CLIENT_SECRET + return getStringFromEnv('GOOGLE_CLIENT_SECRET') } export function getGithubClientId() { - if (!process.env.GITHUB_CLIENT_ID) { - throw new MisconfiguredEnvironmentError('GITHUB_CLIENT_ID env var not configured') - } - - return process.env.GITHUB_CLIENT_ID + return getStringFromEnv('GITHUB_CLIENT_ID') } export function getGithubClientSecret() { - if (!process.env.GITHUB_CLIENT_SECRET) { - throw new MisconfiguredEnvironmentError( - 'GITHUB_CLIENT_SECRET env var not configured' - ) - } - - return process.env.GITHUB_CLIENT_SECRET + return getStringFromEnv('GITHUB_CLIENT_SECRET') } export function getAzureAdIdentityMetadata() { - if (!process.env.AZURE_AD_IDENTITY_METADATA) { - throw new MisconfiguredEnvironmentError( - 'AZURE_AD_IDENTITY_METADATA env var not configured' - ) - } - - return process.env.AZURE_AD_IDENTITY_METADATA + return getStringFromEnv('AZURE_AD_IDENTITY_METADATA') } export function getAzureAdClientId() { - if (!process.env.AZURE_AD_CLIENT_ID) { - throw new MisconfiguredEnvironmentError('AZURE_AD_CLIENT_ID env var not configured') - } - - return process.env.AZURE_AD_CLIENT_ID + return getStringFromEnv('AZURE_AD_CLIENT_ID') } export function getAzureAdIssuer() { @@ -165,7 +123,7 @@ export function getAzureAdClientSecret() { } export function getMailchimpStatus() { - return [true, 'true'].includes(process.env.MAILCHIMP_ENABLED || false) + return getBooleanFromEnv('MAILCHIMP_ENABLED', false) } export function getMailchimpConfig() { @@ -353,12 +311,7 @@ export function getOnboardingStreamCacheBustNumber() { } export function getEmailFromAddress() { - if (!process.env.EMAIL_FROM) { - throw new MisconfiguredEnvironmentError( - 'Email From environment variable (EMAIL_FROM) is not configured' - ) - } - return process.env.EMAIL_FROM + return getStringFromEnv('EMAIL_FROM') } export function getMaximumProjectModelsPerPage() { @@ -371,25 +324,19 @@ export function delayGraphqlResponsesBy() { } export function getAutomateEncryptionKeysPath() { - if (!process.env.AUTOMATE_ENCRYPTION_KEYS_PATH) { - throw new MisconfiguredEnvironmentError( - 'Automate encryption keys path environment variable (AUTOMATE_ENCRYPTION_KEYS_PATH) is not configured' - ) - } - - return process.env.AUTOMATE_ENCRYPTION_KEYS_PATH + return getStringFromEnv('AUTOMATE_ENCRYPTION_KEYS_PATH') } export function getGendoAIKey() { - return process.env.GENDOAI_KEY + return getStringFromEnv('GENDOAI_KEY') } export function getGendoAIResponseKey() { - return process.env.GENDOAI_KEY_RESPONSE + return getStringFromEnv('GENDOAI_KEY_RESPONSE') } export function getGendoAIAPIEndpoint() { - return process.env.GENDOAI_API_ENDPOINT + return getStringFromEnv('GENDOAI_API_ENDPOINT') } export const getFeatureFlags = () => Environment.getFeatureFlags() @@ -415,27 +362,15 @@ export function maximumObjectUploadFileSizeMb() { } export function getS3AccessKey() { - if (!process.env.S3_ACCESS_KEY) - throw new MisconfiguredEnvironmentError( - 'Environment variable S3_ACCESS_KEY is missing' - ) - return process.env.S3_ACCESS_KEY + return getStringFromEnv('S3_ACCESS_KEY') } export function getS3SecretKey() { - if (!process.env.S3_SECRET_KEY) - throw new MisconfiguredEnvironmentError( - 'Environment variable S3_SECRET_KEY is missing' - ) - return process.env.S3_SECRET_KEY + return getStringFromEnv('S3_SECRET_KEY') } export function getS3Endpoint() { - if (!process.env.S3_ENDPOINT) - throw new MisconfiguredEnvironmentError( - 'Environment variable S3_ENDPOINT is missing' - ) - return process.env.S3_ENDPOINT + return getStringFromEnv('S3_ENDPOINT') } export function getS3Region(aDefault: string = 'us-east-1') { @@ -443,11 +378,17 @@ export function getS3Region(aDefault: string = 'us-east-1') { } export function getS3BucketName() { - if (!process.env.S3_BUCKET) - throw new MisconfiguredEnvironmentError('Environment variable S3_BUCKET is missing') - return process.env.S3_BUCKET + return getStringFromEnv('S3_BUCKET') } export function createS3Bucket() { return getBooleanFromEnv('S3_CREATE_BUCKET') } + +export function getStripeApiKey(): string { + return getStringFromEnv('STRIPE_API_KEY') +} + +export function getStripeEndpointSigningKey(): string { + return getStringFromEnv('STRIPE_ENDPOINT_SIGNING_KEY') +} diff --git a/packages/server/modules/workspaces/tests/integration/repositories.spec.ts b/packages/server/modules/workspaces/tests/integration/repositories.spec.ts index 9ef797ef8d..0a7c6a8c39 100644 --- a/packages/server/modules/workspaces/tests/integration/repositories.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/repositories.spec.ts @@ -42,11 +42,11 @@ import { upsertProjectRoleFactory } from '@/modules/core/repositories/streams' import { omit } from 'lodash' +import { createAndStoreTestWorkspaceFactory } from '@/test/speckle-helpers/workspaces' const getWorkspace = getWorkspaceFactory({ db }) const getWorkspaceBySlug = getWorkspaceBySlugFactory({ db }) const getWorkspaceCollaborators = getWorkspaceCollaboratorsFactory({ db }) -const upsertWorkspace = upsertWorkspaceFactory({ db }) const deleteWorkspace = deleteWorkspaceFactory({ db }) const deleteWorkspaceRole = deleteWorkspaceRoleFactory({ db }) const getWorkspaceRoles = getWorkspaceRolesFactory({ db }) @@ -60,6 +60,11 @@ const getUserDiscoverableWorkspaces = getUserDiscoverableWorkspacesFactory({ db const upsertProjectRole = upsertProjectRoleFactory({ db }) const grantStreamPermissions = grantStreamPermissionsFactory({ db }) +const upsertWorkspace = upsertWorkspaceFactory({ db }) +const createAndStoreTestWorkspace = createAndStoreTestWorkspaceFactory({ + upsertWorkspace +}) + const createAndStoreTestUser = async (): Promise => { const testId = cryptoRandomString({ length: 6 }) @@ -76,29 +81,6 @@ const createAndStoreTestUser = async (): Promise => { return userRecord } -const createAndStoreTestWorkspace = async ( - workspaceOverrides: Partial = {} -) => { - const workspace: Omit = { - id: cryptoRandomString({ length: 10 }), - slug: cryptoRandomString({ length: 10 }), - name: cryptoRandomString({ length: 10 }), - createdAt: new Date(), - updatedAt: new Date(), - description: null, - logo: null, - domainBasedMembershipProtectionEnabled: false, - discoverabilityEnabled: false, - defaultLogoIndex: 0, - defaultProjectRole: Roles.Stream.Contributor, - ...workspaceOverrides - } - - await upsertWorkspace({ workspace }) - - return workspace -} - describe('Workspace repositories', () => { describe('getWorkspaceFactory creates a function, that', () => { it('returns null if the workspace is not found', async () => { diff --git a/packages/server/package.json b/packages/server/package.json index f77c5fd25b..064f99bf0a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -102,6 +102,7 @@ "sanitize-html": "^2.7.1", "sharp": "^0.32.6", "string-pixel-width": "^1.10.0", + "stripe": "^17.1.0", "subscriptions-transport-ws": "^0.11.0", "ua-parser-js": "^1.0.38", "undici": "^5.28.4", diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index aa31005ee4..9caf57386a 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -2505,6 +2505,7 @@ export type Query = { * Either token or workspaceId must be specified, or both */ workspaceInvite?: Maybe; + workspacePricingPlans: Scalars['JSONObject']['output']; }; diff --git a/packages/server/test/speckle-helpers/workspaces.ts b/packages/server/test/speckle-helpers/workspaces.ts new file mode 100644 index 0000000000..3f86700f2b --- /dev/null +++ b/packages/server/test/speckle-helpers/workspaces.ts @@ -0,0 +1,27 @@ +import { UpsertWorkspace } from '@/modules/workspaces/domain/operations' +import { Workspace } from '@/modules/workspacesCore/domain/types' +import { Roles } from '@speckle/shared' +import cryptoRandomString from 'crypto-random-string' + +export const createAndStoreTestWorkspaceFactory = + ({ upsertWorkspace }: { upsertWorkspace: UpsertWorkspace }) => + async (workspaceOverrides: Partial = {}) => { + const workspace: Omit = { + id: cryptoRandomString({ length: 10 }), + slug: cryptoRandomString({ length: 10 }), + name: cryptoRandomString({ length: 10 }), + createdAt: new Date(), + updatedAt: new Date(), + description: null, + logo: null, + domainBasedMembershipProtectionEnabled: false, + discoverabilityEnabled: false, + defaultLogoIndex: 0, + defaultProjectRole: Roles.Stream.Contributor, + ...workspaceOverrides + } + + await upsertWorkspace({ workspace }) + + return workspace + } diff --git a/packages/shared/src/environment/index.ts b/packages/shared/src/environment/index.ts index f191596bfa..e7a228849c 100644 --- a/packages/shared/src/environment/index.ts +++ b/packages/shared/src/environment/index.ts @@ -13,13 +13,21 @@ function parseFeatureFlags() { // Enables the gendo ai integration FF_GENDOAI_MODULE_ENABLED: { schema: z.boolean(), - defaults: { production: false, _: true } + defaults: { production: false, _: false } }, // Enables the workspaces module FF_WORKSPACES_MODULE_ENABLED: { schema: z.boolean(), defaults: { production: false, _: true } }, + FF_GATEKEEPER_MODULE_ENABLED: { + schema: z.boolean(), + defaults: { production: false, _: true } + }, + FF_BILLING_INTEGRATION_ENABLED: { + schema: z.boolean(), + defaults: { production: false, _: false } + }, // Enables using dynamic SSO on a per workspace basis FF_WORKSPACES_SSO_ENABLED: { schema: z.boolean(), @@ -46,6 +54,8 @@ export function getFeatureFlags(): { FF_NO_CLOSURE_WRITES: boolean FF_WORKSPACES_MODULE_ENABLED: boolean FF_WORKSPACES_SSO_ENABLED: boolean + FF_GATEKEEPER_MODULE_ENABLED: boolean + FF_BILLING_INTEGRATION_ENABLED: boolean } { if (!parsedFlags) parsedFlags = parseFeatureFlags() return parsedFlags diff --git a/utils/helm/speckle-server/templates/_helpers.tpl b/utils/helm/speckle-server/templates/_helpers.tpl index 0c6516cf96..628fa6c86f 100644 --- a/utils/helm/speckle-server/templates/_helpers.tpl +++ b/utils/helm/speckle-server/templates/_helpers.tpl @@ -580,6 +580,9 @@ Generate the environment variables for Speckle server and Speckle objects deploy - name: FF_MULTIPLE_EMAILS_MODULE_ENABLED value: {{ .Values.featureFlags.multipleEmailsModuleEnabled | quote }} +- name: FF_BILLING_INTEGRATION_ENABLED + value: {{ .Values.featureFlags.billingIntegrationEnabled | quote }} + {{- if .Values.featureFlags.automateModuleEnabled }} - name: SPECKLE_AUTOMATE_URL value: {{ .Values.server.speckleAutomateUrl }} diff --git a/utils/helm/speckle-server/templates/frontend_2/deployment.yml b/utils/helm/speckle-server/templates/frontend_2/deployment.yml index 68db0c528f..54b25c3782 100644 --- a/utils/helm/speckle-server/templates/frontend_2/deployment.yml +++ b/utils/helm/speckle-server/templates/frontend_2/deployment.yml @@ -123,6 +123,8 @@ spec: value: {{ .Values.featureFlags.workspaceSsoEnabled | quote }} - name: NUXT_PUBLIC_FF_MULTIPLE_EMAILS_MODULE_ENABLED value: {{ .Values.featureFlags.multipleEmailsModuleEnabled | quote }} + - name: NUXT_PUBLIC_FF_BILLING_INTEGRATION_ENABLED + value: {{ .Values.featureFlags.billingIntegrationEnabled | quote }} {{- if .Values.analytics.survicate_workspace_key }} - name: NUXT_PUBLIC_SURVICATE_WORKSPACE_KEY value: {{ .Values.analytics.survicate_workspace_key | quote }} diff --git a/utils/helm/speckle-server/values.schema.json b/utils/helm/speckle-server/values.schema.json index a8f0909295..a572fe2105 100644 --- a/utils/helm/speckle-server/values.schema.json +++ b/utils/helm/speckle-server/values.schema.json @@ -64,6 +64,11 @@ "type": "boolean", "description": "High level flag fully toggles multiple emails", "default": false + }, + "billingIntegrationEnabled": { + "type": "boolean", + "description": "High level flag that enables the billing integration", + "default": false } } }, diff --git a/utils/helm/speckle-server/values.yaml b/utils/helm/speckle-server/values.yaml index 438abc12c0..84523b95df 100644 --- a/utils/helm/speckle-server/values.yaml +++ b/utils/helm/speckle-server/values.yaml @@ -47,6 +47,8 @@ featureFlags: workspaceSsoEnabled: false ## @param featureFlags.multipleEmailsModuleEnabled High level flag fully toggles multiple emails multipleEmailsModuleEnabled: false + ## @param featureFlags.billingIntegrationEnabled High level flag that enables the billing integration + billingIntegrationEnabled: false analytics: ## @param analytics.enabled Enable or disable analytics diff --git a/yarn.lock b/yarn.lock index dc56a15008..09998bb9e0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16720,6 +16720,7 @@ __metadata: sanitize-html: "npm:^2.7.1" sharp: "npm:^0.32.6" string-pixel-width: "npm:^1.10.0" + stripe: "npm:^17.1.0" subscriptions-transport-ws: "npm:^0.11.0" supertest: "npm:^4.0.2" ts-node: "npm:^10.9.1" @@ -19853,6 +19854,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:>=8.1.0": + version: 22.7.5 + resolution: "@types/node@npm:22.7.5" + dependencies: + undici-types: "npm:~6.19.2" + checksum: 10/e8ba102f8c1aa7623787d625389be68d64e54fcbb76d41f6c2c64e8cf4c9f4a2370e7ef5e5f1732f3c57529d3d26afdcb2edc0101c5e413a79081449825c57ac + languageName: node + linkType: hard + "@types/node@npm:^13.7.0": version: 13.13.52 resolution: "@types/node@npm:13.13.52" @@ -44854,7 +44864,7 @@ __metadata: languageName: node linkType: hard -"qs@npm:6.13.0": +"qs@npm:6.13.0, qs@npm:^6.11.0": version: 6.13.0 resolution: "qs@npm:6.13.0" dependencies: @@ -48345,6 +48355,16 @@ __metadata: languageName: node linkType: hard +"stripe@npm:^17.1.0": + version: 17.1.0 + resolution: "stripe@npm:17.1.0" + dependencies: + "@types/node": "npm:>=8.1.0" + qs: "npm:^6.11.0" + checksum: 10/ac0e989bfe881bde9fa42f58daee9f953489a2ed8bdef29f601fa80b7d6269928696667355329db2b63fc43c89cedf751316c1756a7e8794a4a016311a58a03b + languageName: node + linkType: hard + "strnum@npm:^1.0.5": version: 1.0.5 resolution: "strnum@npm:1.0.5" @@ -50260,6 +50280,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~6.19.2": + version: 6.19.8 + resolution: "undici-types@npm:6.19.8" + checksum: 10/cf0b48ed4fc99baf56584afa91aaffa5010c268b8842f62e02f752df209e3dea138b372a60a963b3b2576ed932f32329ce7ddb9cb5f27a6c83040d8cd74b7a70 + languageName: node + linkType: hard + "undici@npm:^5.22.1, undici@npm:^5.28.2, undici@npm:^5.28.4": version: 5.28.4 resolution: "undici@npm:5.28.4"