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"