From f8053c609111c04c2410cc006a9cab4acb7d5562 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Wed, 9 Oct 2024 10:42:47 +0200 Subject: [PATCH 01/48] feat(gatekeeper): add gatekeeper module feature flag --- .../server/modules/gatekeeper/domain/types.ts | 3 ++- packages/server/modules/gatekeeper/index.ts | 20 +++++++++++++++++++ packages/server/modules/index.ts | 4 +++- packages/shared/src/environment/index.ts | 5 +++++ 4 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 packages/server/modules/gatekeeper/index.ts diff --git a/packages/server/modules/gatekeeper/domain/types.ts b/packages/server/modules/gatekeeper/domain/types.ts index 0a425384a..82250cd59 100644 --- a/packages/server/modules/gatekeeper/domain/types.ts +++ b/packages/server/modules/gatekeeper/domain/types.ts @@ -1,7 +1,8 @@ import { z } from 'zod' const EnabledModules = z.object({ - workspaces: z.boolean() + workspaces: z.boolean(), + gatekeeper: z.boolean() }) export type EnabledModules = z.infer diff --git a/packages/server/modules/gatekeeper/index.ts b/packages/server/modules/gatekeeper/index.ts new file mode 100644 index 000000000..9ea73d9cf --- /dev/null +++ b/packages/server/modules/gatekeeper/index.ts @@ -0,0 +1,20 @@ +import { SpeckleModule } from '@/modules/shared/helpers/typeHelper' +import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' +import { validateModuleLicense } from '@/modules/gatekeeper/services/validateLicense' + +const { FF_GATEKEEPER_MODULE_ENABLED } = getFeatureFlags() + +const gatekeeperModule: SpeckleModule = { + async init() { + 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.' + ) + } +} +export = gatekeeperModule diff --git a/packages/server/modules/index.ts b/packages/server/modules/index.ts index c8f323ed9..75c18d99d 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/shared/src/environment/index.ts b/packages/shared/src/environment/index.ts index f191596bf..97f0c1f06 100644 --- a/packages/shared/src/environment/index.ts +++ b/packages/shared/src/environment/index.ts @@ -20,6 +20,10 @@ function parseFeatureFlags() { schema: z.boolean(), defaults: { production: false, _: true } }, + FF_GATEKEEPER_MODULE_ENABLED: { + schema: z.boolean(), + defaults: { production: false, _: true } + }, // Enables using dynamic SSO on a per workspace basis FF_WORKSPACES_SSO_ENABLED: { schema: z.boolean(), @@ -46,6 +50,7 @@ export function getFeatureFlags(): { FF_NO_CLOSURE_WRITES: boolean FF_WORKSPACES_MODULE_ENABLED: boolean FF_WORKSPACES_SSO_ENABLED: boolean + FF_GATEKEEPER_MODULE_ENABLED: boolean } { if (!parsedFlags) parsedFlags = parseFeatureFlags() return parsedFlags From eb32874c5787fecacd3136633622977090fe29d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Fri, 11 Oct 2024 07:38:12 +0200 Subject: [PATCH 02/48] feat(gatekeeper): add workspace pricing table domain --- .../lib/common/generated/gql/graphql.ts | 2 + .../gatekeeper/typedefs/gatekeeper.graphql | 3 + .../modules/core/graph/generated/graphql.ts | 2 + .../graph/generated/graphql.ts | 1 + .../gatekeeper/domain/workspacePricing.ts | 106 ++++++++++++++++++ .../gatekeeper/graph/resolvers/index.ts | 15 +++ .../server/test/graphql/generated/graphql.ts | 1 + 7 files changed, 130 insertions(+) create mode 100644 packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql create mode 100644 packages/server/modules/gatekeeper/domain/workspacePricing.ts create mode 100644 packages/server/modules/gatekeeper/graph/resolvers/index.ts diff --git a/packages/frontend-2/lib/common/generated/gql/graphql.ts b/packages/frontend-2/lib/common/generated/gql/graphql.ts index 4cfffbf17..ed3d88309 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']; }; @@ -6913,6 +6914,7 @@ export type QueryFieldArgs = { workspace: QueryWorkspaceArgs, workspaceBySlug: QueryWorkspaceBySlugArgs, workspaceInvite: QueryWorkspaceInviteArgs, + workspacePricingPlans: {}, } export type ResourceIdentifierFieldArgs = { resourceId: {}, diff --git a/packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql b/packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql new file mode 100644 index 000000000..226d42939 --- /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 90276f0b9..11f533e10 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 fb27aac5b..463754861 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/domain/workspacePricing.ts b/packages/server/modules/gatekeeper/domain/workspacePricing.ts new file mode 100644 index 000000000..a4f5473a7 --- /dev/null +++ b/packages/server/modules/gatekeeper/domain/workspacePricing.ts @@ -0,0 +1,106 @@ +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 WorkspacePricingPlan = WorkspaceFeatures & WorkspaceLimits + +const baseFeatures = { + domainBasedSecurityPolicies: true +} + +export const workspacePlans = z.union([ + z.literal('team'), + z.literal('pro'), + z.literal('business'), + z.literal('unlimited') +]) +export type WorkspacePlans = z.infer + +const team: WorkspacePricingPlan = { + ...baseFeatures, + oidcSso: false, + workspaceDataRegionSpecificity: false, + automateMinutes: 300, + uploadSize: 500 +} + +const pro: WorkspacePricingPlan = { + ...baseFeatures, + oidcSso: true, + workspaceDataRegionSpecificity: false, + automateMinutes: 900, + uploadSize: 1000 +} + +const business: WorkspacePricingPlan = { + ...baseFeatures, + oidcSso: true, + workspaceDataRegionSpecificity: true, + automateMinutes: 900, + uploadSize: 1000 +} + +const unlimited: WorkspacePricingPlan = { + ...baseFeatures, + oidcSso: true, + workspaceDataRegionSpecificity: true, + automateMinutes: null, + uploadSize: 1000 +} + +const workspacePricingPlans: Record = { + team, + pro, + business, + unlimited +} + +export const pricingTable = { + workspacePricingPlanInformation, + workspacePricingPlans +} 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 000000000..670558403 --- /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/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index c7829836d..3bb61f4c9 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']; }; From 0a9e1343d17576b2e0fcc8c6922557aa4cee24b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Fri, 11 Oct 2024 21:06:15 +0200 Subject: [PATCH 03/48] feat(gatekeeper): add checkout session creation --- .../modules/gatekeeper/clients/stripe.ts | 68 +++++++ .../modules/gatekeeper/domain/billing.ts | 37 ++++ .../gatekeeper/domain/workspacePricing.ts | 35 ++-- .../modules/gatekeeper/errors/billing.ts | 13 ++ packages/server/modules/gatekeeper/index.ts | 8 +- .../gatekeeper/repositories/billing.ts | 8 + .../server/modules/gatekeeper/rest/billing.ts | 179 ++++++++++++++++++ .../modules/gatekeeper/services/workspaces.ts | 77 ++++++++ .../modules/shared/helpers/envHelper.ts | 46 +++++ packages/server/package.json | 1 + packages/shared/src/environment/index.ts | 5 + yarn.lock | 29 ++- 12 files changed, 492 insertions(+), 14 deletions(-) create mode 100644 packages/server/modules/gatekeeper/clients/stripe.ts create mode 100644 packages/server/modules/gatekeeper/domain/billing.ts create mode 100644 packages/server/modules/gatekeeper/errors/billing.ts create mode 100644 packages/server/modules/gatekeeper/repositories/billing.ts create mode 100644 packages/server/modules/gatekeeper/rest/billing.ts create mode 100644 packages/server/modules/gatekeeper/services/workspaces.ts diff --git a/packages/server/modules/gatekeeper/clients/stripe.ts b/packages/server/modules/gatekeeper/clients/stripe.ts new file mode 100644 index 000000000..19b55592a --- /dev/null +++ b/packages/server/modules/gatekeeper/clients/stripe.ts @@ -0,0 +1,68 @@ +import { CreateCheckoutSession } 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', + // eslint-disable-next-line camelcase + line_items: costLineItems, + // eslint-disable-next-line camelcase + success_url: `${resultUrl.toString()}&payment_status=success&session_id={CHECKOUT_SESSION_ID}`, + // eslint-disable-next-line camelcase + 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, + paymentStatus: 'unpaid' + } + } diff --git a/packages/server/modules/gatekeeper/domain/billing.ts b/packages/server/modules/gatekeeper/domain/billing.ts new file mode 100644 index 000000000..ddd12f877 --- /dev/null +++ b/packages/server/modules/gatekeeper/domain/billing.ts @@ -0,0 +1,37 @@ +import { + WorkspacePlanBillingIntervals, + WorkspacePlans +} from '@/modules/gatekeeper/domain/workspacePricing' + +export type WorkspacePlanStatus = + | 'trial' + | 'valid' + // | 'paymentNeeded' // unsure if this is needed + | 'paymentFailed' + | 'cancelled' + +export type GetWorkspacePlan = (args: { + workspaceId: string +}) => Promise<{ name: WorkspacePlans; status: WorkspacePlanStatus } | null> + +export type CheckoutSession = { + url: string + id: string + workspaceId: string + workspacePlan: WorkspacePlans + billingInterval: WorkspacePlanBillingIntervals + paymentStatus: 'paid' | 'unpaid' +} + +export type StoreCheckoutSession = (args: { + checkoutSession: CheckoutSession +}) => Promise + +export type CreateCheckoutSession = (args: { + workspaceId: string + workspaceSlug: string + seatCount: number + guestCount: number + workspacePlan: WorkspacePlans + billingInterval: WorkspacePlanBillingIntervals +}) => Promise diff --git a/packages/server/modules/gatekeeper/domain/workspacePricing.ts b/packages/server/modules/gatekeeper/domain/workspacePricing.ts index a4f5473a7..659cb6de4 100644 --- a/packages/server/modules/gatekeeper/domain/workspacePricing.ts +++ b/packages/server/modules/gatekeeper/domain/workspacePricing.ts @@ -56,11 +56,24 @@ const baseFeatures = { export const workspacePlans = z.union([ z.literal('team'), z.literal('pro'), - z.literal('business'), - z.literal('unlimited') + z.literal('business') + // this will be usefull in the enterprise and self hoster deployments + // z.literal('unlimited') ]) +// 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 = WorkspacePlans | 'guest' + +export const workspacePlanBillingIntervals = z.union([ + z.literal('monthly'), + z.literal('yearly') +]) +export type WorkspacePlanBillingIntervals = z.infer< + typeof workspacePlanBillingIntervals +> + const team: WorkspacePricingPlan = { ...baseFeatures, oidcSso: false, @@ -85,19 +98,19 @@ const business: WorkspacePricingPlan = { uploadSize: 1000 } -const unlimited: WorkspacePricingPlan = { - ...baseFeatures, - oidcSso: true, - workspaceDataRegionSpecificity: true, - automateMinutes: null, - uploadSize: 1000 -} +// const unlimited: WorkspacePricingPlan = { +// ...baseFeatures, +// oidcSso: true, +// workspaceDataRegionSpecificity: true, +// automateMinutes: null, +// uploadSize: 1000 +// } const workspacePricingPlans: Record = { team, pro, - business, - unlimited + business + //unlimited } export const pricingTable = { diff --git a/packages/server/modules/gatekeeper/errors/billing.ts b/packages/server/modules/gatekeeper/errors/billing.ts new file mode 100644 index 000000000..749e3279c --- /dev/null +++ b/packages/server/modules/gatekeeper/errors/billing.ts @@ -0,0 +1,13 @@ +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 WorkspaceAlreadyPaidError extends BaseError { + static defaultMessage = 'Workspace is already on a paid plan' + static code = 'WORKSPACE_ALREADY_PAID_ERROR' + static statusCode = 400 +} diff --git a/packages/server/modules/gatekeeper/index.ts b/packages/server/modules/gatekeeper/index.ts index 9ea73d9cf..88dccd94b 100644 --- a/packages/server/modules/gatekeeper/index.ts +++ b/packages/server/modules/gatekeeper/index.ts @@ -1,11 +1,13 @@ 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 } = getFeatureFlags() +const { FF_GATEKEEPER_MODULE_ENABLED, FF_BILLING_INTEGRATION_ENABLED } = + getFeatureFlags() const gatekeeperModule: SpeckleModule = { - async init() { + async init(app, isInitial) { if (!FF_GATEKEEPER_MODULE_ENABLED) return const isLicenseValid = await validateModuleLicense({ @@ -15,6 +17,8 @@ const gatekeeperModule: SpeckleModule = { throw new Error( 'The gatekeeper module needs a valid license to run, contact Speckle to get one.' ) + + if (isInitial && FF_BILLING_INTEGRATION_ENABLED) app.use(billingRouter) } } export = gatekeeperModule diff --git a/packages/server/modules/gatekeeper/repositories/billing.ts b/packages/server/modules/gatekeeper/repositories/billing.ts new file mode 100644 index 000000000..73a5ab10d --- /dev/null +++ b/packages/server/modules/gatekeeper/repositories/billing.ts @@ -0,0 +1,8 @@ +import { GetWorkspacePlan } from '@/modules/gatekeeper/domain/billing' + +export const getWorkspacePlanFactory = (): GetWorkspacePlan => () => { + // should throw for not found workspaces + return new Promise((resolve) => { + resolve({ name: 'team', status: 'trial' }) + }) +} diff --git a/packages/server/modules/gatekeeper/rest/billing.ts b/packages/server/modules/gatekeeper/rest/billing.ts new file mode 100644 index 000000000..dd2ac3afc --- /dev/null +++ b/packages/server/modules/gatekeeper/rest/billing.ts @@ -0,0 +1,179 @@ +import { Router } from 'express' +import { validateRequest } from 'zod-express' +import { z } from 'zod' +import { authorizeResolver } from '@/modules/shared' +import { ensureError, Roles, throwUncoveredError } from '@speckle/shared' +import { Stripe } from 'stripe' +import { + getFrontendOrigin, + getStripeApiKey, + getStripeEndpointSigningKey, + getWorkspaceBusinessSeatStripePriceId, + getWorkspaceGuestSeatStripePriceId, + getWorkspaceProSeatStripePriceId, + getWorkspaceTeamSeatStripePriceId +} from '@/modules/shared/helpers/envHelper' +import { + WorkspacePlanBillingIntervals, + workspacePlans, + WorkspacePricingPlans +} from '@/modules/gatekeeper/domain/workspacePricing' +import { + countWorkspaceRoleWithOptionalProjectRoleFactory, + getWorkspaceBySlugFactory +} from '@/modules/workspaces/repositories/workspaces' +import { db } from '@/db/knex' +import { startCheckoutSessionFactory } from '@/modules/gatekeeper/services/workspaces' +import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace' +import { createCheckoutSessionFactory } from '@/modules/gatekeeper/clients/stripe' +import { CheckoutSession } from '@/modules/gatekeeper/domain/billing' +import { getWorkspacePlanFactory } from '@/modules/gatekeeper/repositories/billing' + +const router = Router() + +export default router + +const stripe = new Stripe(getStripeApiKey(), { typescript: true }) + +let checkoutSession: CheckoutSession | undefined + +const getWorkspacePlanPrice = ({ + workspacePlan +}: { + workspacePlan: WorkspacePricingPlans + billingInterval: WorkspacePlanBillingIntervals +}): string => { + // right now, ignoring interval + switch (workspacePlan) { + case 'team': + return getWorkspaceTeamSeatStripePriceId() + case 'pro': + return getWorkspaceProSeatStripePriceId() + case 'business': + return getWorkspaceBusinessSeatStripePriceId() + case 'guest': + return getWorkspaceGuestSeatStripePriceId() + default: + throwUncoveredError(workspacePlan) + } +} + +router.get( + '/api/v1/billing/workspaces/:workspaceSlug/checkout-session/:workspacePlan', + validateRequest({ + params: z.object({ + workspaceSlug: z.string().min(1), + workspacePlan: workspacePlans + }) + }), + async (req) => { + const { workspaceSlug, workspacePlan } = req.params + const workspace = await getWorkspaceBySlugFactory({ db })({ workspaceSlug }) + if (!workspace) throw new WorkspaceNotFoundError() + + const workspaceId = workspace.id + 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({ + getWorkspacePlan: getWorkspacePlanFactory(), + countRole, + createCheckoutSession, + storeCheckoutSession: async (args: { checkoutSession: CheckoutSession }) => { + checkoutSession = args.checkoutSession + } + })({ workspacePlan, workspaceId, workspaceSlug, billingInterval: 'monthly' }) + + req.res?.redirect(session.url) + } +) + +// const fulfillCheckoutFactory = +// ({ stripe }: { stripe: Stripe }) => +// async ({ sessionId }: { sessionId: string }) => { +// // Set your secret key. Remember to switch to your live secret key in production. +// // See your keys here: https://dashboard.stripe.com/apikeys + +// console.log('Fulfilling Checkout Session ' + sessionId) + +// // TODO: Make this function safe to run multiple times, +// // even concurrently, with the same session ID + +// // TODO: Make sure fulfillment hasn't already been +// // peformed for this Checkout Session + +// // Retrieve the Checkout Session from the API with line_items expanded +// const checkoutSession = await stripe.checkout.sessions.retrieve(sessionId, { +// expand: ['line_items'] +// }) + +// // Check the Checkout Session's payment_status property +// // to determine if fulfillment should be peformed +// if (checkoutSession.payment_status !== 'unpaid') { +// // TODO: Perform fulfillment of the line items +// // TODO: Record/save fulfillment status for this +// // Checkout Session +// } +// } + +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(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 + break + case 'checkout.session.async_payment_succeeded': + case 'checkout.session.completed': + const session = event.data.object + + if (session.payment_status !== 'unpaid') { + // IDK yet, but 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 + } + + if (checkoutSession?.id !== session.id) throw new Error('session mismatch') + // move the workspace plan to the new plan + case 'checkout.session.expired': + 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/workspaces.ts b/packages/server/modules/gatekeeper/services/workspaces.ts new file mode 100644 index 000000000..506d052b4 --- /dev/null +++ b/packages/server/modules/gatekeeper/services/workspaces.ts @@ -0,0 +1,77 @@ +import { + CheckoutSession, + CreateCheckoutSession, + GetWorkspacePlan, + StoreCheckoutSession +} from '@/modules/gatekeeper/domain/billing' +import { + WorkspacePlanBillingIntervals, + WorkspacePlans +} from '@/modules/gatekeeper/domain/workspacePricing' +import { WorkspaceAlreadyPaidError } from '@/modules/gatekeeper/errors/billing' +import { CountWorkspaceRoleWithOptionalProjectRole } from '@/modules/workspaces/domain/operations' +import { Roles, throwUncoveredError } from '@speckle/shared' + +export const startCheckoutSessionFactory = + ({ + getWorkspacePlan, + countRole, + createCheckoutSession, + storeCheckoutSession + }: { + getWorkspacePlan: GetWorkspacePlan + countRole: CountWorkspaceRoleWithOptionalProjectRole + createCheckoutSession: CreateCheckoutSession + storeCheckoutSession: StoreCheckoutSession + }) => + async ({ + workspaceId, + workspaceSlug, + workspacePlan, + billingInterval + }: { + workspaceId: string + workspaceSlug: string + workspacePlan: WorkspacePlans + 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 }) + 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.status) + } + } + 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 storeCheckoutSession({ checkoutSession }) + return checkoutSession + } diff --git a/packages/server/modules/shared/helpers/envHelper.ts b/packages/server/modules/shared/helpers/envHelper.ts index ebf2204cd..72c81f931 100644 --- a/packages/server/modules/shared/helpers/envHelper.ts +++ b/packages/server/modules/shared/helpers/envHelper.ts @@ -451,3 +451,49 @@ export function getS3BucketName() { export function createS3Bucket() { return getBooleanFromEnv('S3_CREATE_BUCKET') } + +export function getStripeApiKey(): string { + if (!process.env.STRIPE_API_KEY) + throw new MisconfiguredEnvironmentError( + 'Environment variable STRIPE_API_KEY is missing' + ) + return process.env.STRIPE_API_KEY +} + +export function getStripeEndpointSigningKey(): string { + if (!process.env.STRIPE_ENDPOINT_SIGNING_KEY) + throw new MisconfiguredEnvironmentError( + 'Environment variable STRIPE_ENDPOINT_SIGNING_KEY is missing' + ) + return process.env.STRIPE_ENDPOINT_SIGNING_KEY +} + +export function getWorkspaceGuestSeatStripePriceId(): string { + if (!process.env.WORKSPACE_GUEST_SEAT_STRIPE_PRICE_ID) + throw new MisconfiguredEnvironmentError( + 'Environment variable WORKSPACE_GUEST_SEAT_STRIPE_PRICE_ID is missing' + ) + return process.env.WORKSPACE_GUEST_SEAT_STRIPE_PRICE_ID +} +export function getWorkspaceTeamSeatStripePriceId(): string { + if (!process.env.WORKSPACE_TEAM_SEAT_STRIPE_PRICE_ID) + throw new MisconfiguredEnvironmentError( + 'Environment variable WORKSPACE_TEAM_SEAT_STRIPE_PRICE_ID is missing' + ) + return process.env.WORKSPACE_TEAM_SEAT_STRIPE_PRICE_ID +} + +export function getWorkspaceProSeatStripePriceId(): string { + if (!process.env.WORKSPACE_PRO_SEAT_STRIPE_PRICE_ID) + throw new MisconfiguredEnvironmentError( + 'Environment variable WORKSPACE_PRO_SEAT_STRIPE_PRICE_ID is missing' + ) + return process.env.WORKSPACE_PRO_SEAT_STRIPE_PRICE_ID +} +export function getWorkspaceBusinessSeatStripePriceId(): string { + if (!process.env.WORKSPACE_BUSINESS_SEAT_STRIPE_PRICE_ID) + throw new MisconfiguredEnvironmentError( + 'Environment variable WORKSPACE_BUSINESS_SEAT_STRIPE_PRICE_ID is missing' + ) + return process.env.WORKSPACE_BUSINESS_SEAT_STRIPE_PRICE_ID +} diff --git a/packages/server/package.json b/packages/server/package.json index f77c5fd25..064f99bf0 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/shared/src/environment/index.ts b/packages/shared/src/environment/index.ts index 97f0c1f06..e4f780036 100644 --- a/packages/shared/src/environment/index.ts +++ b/packages/shared/src/environment/index.ts @@ -24,6 +24,10 @@ function parseFeatureFlags() { 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(), @@ -51,6 +55,7 @@ export function getFeatureFlags(): { 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/yarn.lock b/yarn.lock index dc56a1500..09998bb9e 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" From 43c57c4225c6992f86cdcecaceddee2d456dd600 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Fri, 11 Oct 2024 21:57:22 +0200 Subject: [PATCH 04/48] feat(gatekeeper): verify stripe signature --- packages/server/app.ts | 10 +++++++++- packages/server/modules/gatekeeper/rest/billing.ts | 7 ++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/server/app.ts b/packages/server/app.ts index 249b37f09..a35a5c366 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/modules/gatekeeper/rest/billing.ts b/packages/server/modules/gatekeeper/rest/billing.ts index dd2ac3afc..205d0bffc 100644 --- a/packages/server/modules/gatekeeper/rest/billing.ts +++ b/packages/server/modules/gatekeeper/rest/billing.ts @@ -139,7 +139,12 @@ router.post('/api/v1/billing/webhooks', async (req, res) => { let event: Stripe.Event try { - event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret) + 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 From ed543c5ecf6bafb4abc916e7d117947203dd3151 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Mon, 14 Oct 2024 10:35:44 +0200 Subject: [PATCH 05/48] wip(gatekeeper): checkout callbacks --- .../modules/gatekeeper/domain/billing.ts | 31 +++++++--- .../gatekeeper/repositories/billing.ts | 61 ++++++++++++++++--- .../server/modules/gatekeeper/rest/billing.ts | 21 ++++--- .../modules/gatekeeper/services/workspaces.ts | 32 ++++++++-- 4 files changed, 117 insertions(+), 28 deletions(-) diff --git a/packages/server/modules/gatekeeper/domain/billing.ts b/packages/server/modules/gatekeeper/domain/billing.ts index ddd12f877..cb74dfda6 100644 --- a/packages/server/modules/gatekeeper/domain/billing.ts +++ b/packages/server/modules/gatekeeper/domain/billing.ts @@ -10,23 +10,40 @@ export type WorkspacePlanStatus = | 'paymentFailed' | 'cancelled' +export type WorkspacePlan = { + workspaceId: string + name: WorkspacePlans + status: WorkspacePlanStatus +} + export type GetWorkspacePlan = (args: { workspaceId: string -}) => Promise<{ name: WorkspacePlans; status: WorkspacePlanStatus } | null> +}) => Promise -export type CheckoutSession = { - url: string +export type UpsertWorkspacePlan = (args: { + workspacePlan: WorkspacePlan +}) => Promise + +export type SessionInput = { id: string - workspaceId: string - workspacePlan: WorkspacePlans - billingInterval: WorkspacePlanBillingIntervals paymentStatus: 'paid' | 'unpaid' } -export type StoreCheckoutSession = (args: { +export type CheckoutSession = SessionInput & { + url: string + workspaceId: string + workspacePlan: WorkspacePlans + billingInterval: WorkspacePlanBillingIntervals +} + +export type SaveCheckoutSession = (args: { checkoutSession: CheckoutSession }) => Promise +export type GetCheckoutSession = (args: { + sessionId: string +}) => Promise + export type CreateCheckoutSession = (args: { workspaceId: string workspaceSlug: string diff --git a/packages/server/modules/gatekeeper/repositories/billing.ts b/packages/server/modules/gatekeeper/repositories/billing.ts index 73a5ab10d..a30249866 100644 --- a/packages/server/modules/gatekeeper/repositories/billing.ts +++ b/packages/server/modules/gatekeeper/repositories/billing.ts @@ -1,8 +1,55 @@ -import { GetWorkspacePlan } from '@/modules/gatekeeper/domain/billing' +import { + CheckoutSession, + GetCheckoutSession, + GetWorkspacePlan, + SaveCheckoutSession, + UpsertWorkspacePlan, + WorkspacePlan +} from '@/modules/gatekeeper/domain/billing' -export const getWorkspacePlanFactory = (): GetWorkspacePlan => () => { - // should throw for not found workspaces - return new Promise((resolve) => { - resolve({ name: 'team', status: 'trial' }) - }) -} +export const getWorkspacePlanFactory = + (): GetWorkspacePlan => + ({ workspaceId }) => { + const maybePlan = workspacePlans.find((plan) => plan.workspaceId === workspaceId) + return new Promise((resolve) => { + resolve(maybePlan || null) + }) + } + +const workspacePlans: WorkspacePlan[] = [] + +export const upsertWorkspacePlanFactory = + (): UpsertWorkspacePlan => + ({ workspacePlan }) => { + const maybePlan = workspacePlans.find( + (plan) => plan.workspaceId === workspacePlan.workspaceId + ) + if (maybePlan) { + maybePlan.name = workspacePlan.name + maybePlan.status = workspacePlan.status + } else { + workspacePlans.push(workspacePlan) + } + return new Promise((resolve) => { + resolve() + }) + } + +const checkoutSessions: CheckoutSession[] = [] + +export const saveCheckoutSessionFactory = + (): SaveCheckoutSession => + ({ checkoutSession }) => { + checkoutSessions.push(checkoutSession) + return new Promise((resolve) => { + resolve() + }) + } + +export const getCheckoutSessionFactory = + (): GetCheckoutSession => + ({ sessionId }) => { + return new Promise((resolve) => { + resolve(checkoutSessions.find((session) => session.id === sessionId) || null) + }) + } diff --git a/packages/server/modules/gatekeeper/rest/billing.ts b/packages/server/modules/gatekeeper/rest/billing.ts index 205d0bffc..d1cafa181 100644 --- a/packages/server/modules/gatekeeper/rest/billing.ts +++ b/packages/server/modules/gatekeeper/rest/billing.ts @@ -26,8 +26,10 @@ import { db } from '@/db/knex' import { startCheckoutSessionFactory } from '@/modules/gatekeeper/services/workspaces' import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace' import { createCheckoutSessionFactory } from '@/modules/gatekeeper/clients/stripe' -import { CheckoutSession } from '@/modules/gatekeeper/domain/billing' -import { getWorkspacePlanFactory } from '@/modules/gatekeeper/repositories/billing' +import { + getWorkspacePlanFactory, + saveCheckoutSessionFactory +} from '@/modules/gatekeeper/repositories/billing' const router = Router() @@ -35,8 +37,6 @@ export default router const stripe = new Stripe(getStripeApiKey(), { typescript: true }) -let checkoutSession: CheckoutSession | undefined - const getWorkspacePlanPrice = ({ workspacePlan }: { @@ -91,9 +91,7 @@ router.get( getWorkspacePlan: getWorkspacePlanFactory(), countRole, createCheckoutSession, - storeCheckoutSession: async (args: { checkoutSession: CheckoutSession }) => { - checkoutSession = args.checkoutSession - } + saveCheckoutSession: saveCheckoutSessionFactory() })({ workspacePlan, workspaceId, workspaceSlug, billingInterval: 'monthly' }) req.res?.redirect(session.url) @@ -152,25 +150,28 @@ router.post('/api/v1/billing/webhooks', async (req, res) => { switch (event.type) { case 'checkout.session.async_payment_failed': - // TODO: need to alert the user + // 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 + session.subscription + if (session.payment_status !== 'unpaid') { - // IDK yet, but if the workspace is already on a paid plan, we made a bo bo. + // 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 } - if (checkoutSession?.id !== session.id) throw new Error('session mismatch') // move the workspace plan to the new plan case 'checkout.session.expired': + // delete the checkout session from the DB default: break } diff --git a/packages/server/modules/gatekeeper/services/workspaces.ts b/packages/server/modules/gatekeeper/services/workspaces.ts index 506d052b4..4e13f990d 100644 --- a/packages/server/modules/gatekeeper/services/workspaces.ts +++ b/packages/server/modules/gatekeeper/services/workspaces.ts @@ -1,8 +1,10 @@ import { CheckoutSession, CreateCheckoutSession, + GetCheckoutSession, GetWorkspacePlan, - StoreCheckoutSession + SessionInput, + SaveCheckoutSession } from '@/modules/gatekeeper/domain/billing' import { WorkspacePlanBillingIntervals, @@ -17,12 +19,12 @@ export const startCheckoutSessionFactory = getWorkspacePlan, countRole, createCheckoutSession, - storeCheckoutSession + saveCheckoutSession }: { getWorkspacePlan: GetWorkspacePlan countRole: CountWorkspaceRoleWithOptionalProjectRole createCheckoutSession: CreateCheckoutSession - storeCheckoutSession: StoreCheckoutSession + saveCheckoutSession: SaveCheckoutSession }) => async ({ workspaceId, @@ -72,6 +74,28 @@ export const startCheckoutSessionFactory = seatCount: adminCount + memberCount }) - await storeCheckoutSession({ checkoutSession }) + await saveCheckoutSession({ checkoutSession }) return checkoutSession } + +export const completeCheckoutSessionFactory = + ({ getCheckoutSession }: { getCheckoutSession: GetCheckoutSession }) => + async ({ session }: { session: SessionInput }): Promise => { + const checkoutSession = await getCheckoutSession({ sessionId: session.id }) + if (!checkoutSession && session.paymentStatus === 'paid') + throw new Error('checkout session is not found this is a bo bo') + // idk what to do here, if there is no checkout session, it prob fine, could be a replay etc + // but the more schematically correct thing would be, to throw an error + if (!checkoutSession) return + + // if statuses match, nothing to do + if (session.paymentStatus === checkoutSession.paymentStatus) return + + // update checkout session, to have the input payment status + // prob in this case, we should not be allowing a to move a paid checkout session to paid + + if (session.paymentStatus === 'paid') { + // move workspace to the plan, and payment status valid + // save the workspace subscription information in the DB + } + } From 76a4fa8fdd85477a9aee2f8b66e7ce030b409045 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Tue, 15 Oct 2024 09:07:04 +0200 Subject: [PATCH 06/48] feat(gatekeeper): add unlimited and academia plan types --- .../modules/gatekeeper/domain/billing.ts | 77 ++++++++++++++++--- .../gatekeeper/domain/workspacePricing.ts | 72 ++++++++++++----- 2 files changed, 120 insertions(+), 29 deletions(-) diff --git a/packages/server/modules/gatekeeper/domain/billing.ts b/packages/server/modules/gatekeeper/domain/billing.ts index cb74dfda6..e40500d8a 100644 --- a/packages/server/modules/gatekeeper/domain/billing.ts +++ b/packages/server/modules/gatekeeper/domain/billing.ts @@ -1,38 +1,68 @@ import { + TrialWorkspacePlans, + PaidWorkspacePlans, + UnpaidWorkspacePlans, WorkspacePlanBillingIntervals, - WorkspacePlans + WorkspacePricingPlans } from '@/modules/gatekeeper/domain/workspacePricing' -export type WorkspacePlanStatus = - | 'trial' - | 'valid' +export type UnpaidWorkspacePlanStatuses = 'valid' + +export type PaidWorkspacePlanStatuses = + | UnpaidWorkspacePlanStatuses // | 'paymentNeeded' // unsure if this is needed | 'paymentFailed' | 'cancelled' -export type WorkspacePlan = { +export type TrialWorkspacePlanStatuses = 'trial' + +export type PaidWorkspacePlan = { workspaceId: string - name: WorkspacePlans - status: WorkspacePlanStatus + name: PaidWorkspacePlans + status: PaidWorkspacePlanStatuses } +export type TrialWorkspacePlan = { + workspaceId: string + name: TrialWorkspacePlans + status: TrialWorkspacePlanStatuses +} + +export type UnpaidWorkspacePlan = { + workspaceId: string + 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 - paymentStatus: 'paid' | 'unpaid' } +export type SessionPaymentStatus = 'paid' | 'unpaid' + export type CheckoutSession = SessionInput & { url: string workspaceId: string - workspacePlan: WorkspacePlans + workspacePlan: PaidWorkspacePlans + paymentStatus: SessionPaymentStatus billingInterval: WorkspacePlanBillingIntervals } @@ -44,11 +74,38 @@ export type GetCheckoutSession = (args: { sessionId: string }) => Promise +export type UpdateCheckoutSessionStatus = (args: { + sessionId: string + paymentStatus: SessionPaymentStatus +}) => Promise + export type CreateCheckoutSession = (args: { workspaceId: string workspaceSlug: string seatCount: number guestCount: number - workspacePlan: WorkspacePlans + workspacePlan: PaidWorkspacePlans billingInterval: WorkspacePlanBillingIntervals }) => Promise + +export type WorkspaceSubscription = { + workspaceId: string + createdAt: Date + billingInterval: WorkspacePlanBillingIntervals + subscriptionData: SubscriptionData +} + +// this abstracts the stripe sub data +export type SubscriptionData = { + subscriptionId: string + customerId: string + products: { + workspacePlan: WorkspacePricingPlans + priceId: string + quantity: number + }[] +} + +export type SaveWorkspaceSubscription = (args: { + workspaceSubscription: WorkspaceSubscription +}) => Promise diff --git a/packages/server/modules/gatekeeper/domain/workspacePricing.ts b/packages/server/modules/gatekeeper/domain/workspacePricing.ts index 659cb6de4..d9c9e5230 100644 --- a/packages/server/modules/gatekeeper/domain/workspacePricing.ts +++ b/packages/server/modules/gatekeeper/domain/workspacePricing.ts @@ -47,24 +47,39 @@ export const workspacePricingPlanInformation = { features, limits } type WorkspaceLimits = Record -type WorkspacePricingPlan = WorkspaceFeatures & WorkspaceLimits +type WorkspacePlanFeaturesAndLimits = WorkspaceFeatures & WorkspaceLimits const baseFeatures = { domainBasedSecurityPolicies: true } -export const workspacePlans = z.union([ - z.literal('team'), +export const trialWorkspacePlans = z.literal('team') + +export type TrialWorkspacePlans = z.infer + +export const paidWorkspacePlans = z.union([ + trialWorkspacePlans, z.literal('pro'), z.literal('business') - // this will be usefull in the enterprise and self hoster deployments - // z.literal('unlimited') ]) + +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 = WorkspacePlans | 'guest' +export type WorkspacePricingPlans = PaidWorkspacePlans | 'guest' export const workspacePlanBillingIntervals = z.union([ z.literal('monthly'), @@ -74,7 +89,7 @@ export type WorkspacePlanBillingIntervals = z.infer< typeof workspacePlanBillingIntervals > -const team: WorkspacePricingPlan = { +const team: WorkspacePlanFeaturesAndLimits = { ...baseFeatures, oidcSso: false, workspaceDataRegionSpecificity: false, @@ -82,7 +97,7 @@ const team: WorkspacePricingPlan = { uploadSize: 500 } -const pro: WorkspacePricingPlan = { +const pro: WorkspacePlanFeaturesAndLimits = { ...baseFeatures, oidcSso: true, workspaceDataRegionSpecificity: false, @@ -90,7 +105,7 @@ const pro: WorkspacePricingPlan = { uploadSize: 1000 } -const business: WorkspacePricingPlan = { +const business: WorkspacePlanFeaturesAndLimits = { ...baseFeatures, oidcSso: true, workspaceDataRegionSpecificity: true, @@ -98,22 +113,41 @@ const business: WorkspacePricingPlan = { uploadSize: 1000 } -// const unlimited: WorkspacePricingPlan = { -// ...baseFeatures, -// oidcSso: true, -// workspaceDataRegionSpecificity: true, -// automateMinutes: null, -// uploadSize: 1000 -// } +const unlimited: WorkspacePlanFeaturesAndLimits = { + ...baseFeatures, + oidcSso: true, + workspaceDataRegionSpecificity: true, + automateMinutes: null, + uploadSize: 1000 +} -const workspacePricingPlans: Record = { +const academia: WorkspacePlanFeaturesAndLimits = { + ...baseFeatures, + oidcSso: true, + workspaceDataRegionSpecificity: false, + automateMinutes: null, + uploadSize: 100 +} + +const paidWorkspacePlanFeatures: Record< + PaidWorkspacePlans, + WorkspacePlanFeaturesAndLimits +> = { team, pro, business - //unlimited + // unlimited +} + +export const unpaidWorkspacePlanFeatures: Record< + UnpaidWorkspacePlans, + WorkspacePlanFeaturesAndLimits +> = { + academia, + unlimited } export const pricingTable = { workspacePricingPlanInformation, - workspacePricingPlans + workspacePlanInformation: paidWorkspacePlanFeatures } From 88bc01ff7c39580021d19f82ece7442ffeae88c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Wed, 16 Oct 2024 22:11:35 +0200 Subject: [PATCH 07/48] refactor(envHelper): getStringFromEnv helper --- .../modules/shared/helpers/envHelper.ts | 168 ++++-------------- 1 file changed, 31 insertions(+), 137 deletions(-) diff --git a/packages/server/modules/shared/helpers/envHelper.ts b/packages/server/modules/shared/helpers/envHelper.ts index 72c81f931..7ac50cad4 100644 --- a/packages/server/modules/shared/helpers/envHelper.ts +++ b/packages/server/modules/shared/helpers/envHelper.ts @@ -48,7 +48,14 @@ 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 { + if (!process.env[envVarKey]) { + throw new MisconfiguredEnvironmentError(`${envVarKey} env var not configured`) + } + return process.env[envVarKey] } /** @@ -63,97 +70,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 +122,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 +310,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 +323,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 +361,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,9 +377,7 @@ 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() { @@ -453,47 +385,9 @@ export function createS3Bucket() { } export function getStripeApiKey(): string { - if (!process.env.STRIPE_API_KEY) - throw new MisconfiguredEnvironmentError( - 'Environment variable STRIPE_API_KEY is missing' - ) - return process.env.STRIPE_API_KEY + return getStringFromEnv('STRIPE_API_KEY') } export function getStripeEndpointSigningKey(): string { - if (!process.env.STRIPE_ENDPOINT_SIGNING_KEY) - throw new MisconfiguredEnvironmentError( - 'Environment variable STRIPE_ENDPOINT_SIGNING_KEY is missing' - ) - return process.env.STRIPE_ENDPOINT_SIGNING_KEY -} - -export function getWorkspaceGuestSeatStripePriceId(): string { - if (!process.env.WORKSPACE_GUEST_SEAT_STRIPE_PRICE_ID) - throw new MisconfiguredEnvironmentError( - 'Environment variable WORKSPACE_GUEST_SEAT_STRIPE_PRICE_ID is missing' - ) - return process.env.WORKSPACE_GUEST_SEAT_STRIPE_PRICE_ID -} -export function getWorkspaceTeamSeatStripePriceId(): string { - if (!process.env.WORKSPACE_TEAM_SEAT_STRIPE_PRICE_ID) - throw new MisconfiguredEnvironmentError( - 'Environment variable WORKSPACE_TEAM_SEAT_STRIPE_PRICE_ID is missing' - ) - return process.env.WORKSPACE_TEAM_SEAT_STRIPE_PRICE_ID -} - -export function getWorkspaceProSeatStripePriceId(): string { - if (!process.env.WORKSPACE_PRO_SEAT_STRIPE_PRICE_ID) - throw new MisconfiguredEnvironmentError( - 'Environment variable WORKSPACE_PRO_SEAT_STRIPE_PRICE_ID is missing' - ) - return process.env.WORKSPACE_PRO_SEAT_STRIPE_PRICE_ID -} -export function getWorkspaceBusinessSeatStripePriceId(): string { - if (!process.env.WORKSPACE_BUSINESS_SEAT_STRIPE_PRICE_ID) - throw new MisconfiguredEnvironmentError( - 'Environment variable WORKSPACE_BUSINESS_SEAT_STRIPE_PRICE_ID is missing' - ) - return process.env.WORKSPACE_BUSINESS_SEAT_STRIPE_PRICE_ID + return getStringFromEnv('STRIPE_ENDPOINT_SIGNING_KEY') } From 8559dfb059747d4ff83c6eeedf5bd9a60f1e35f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Thu, 17 Oct 2024 06:52:39 +0200 Subject: [PATCH 08/48] chore(gatekeeper): add future todos --- packages/server/modules/gatekeeper/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/server/modules/gatekeeper/index.ts b/packages/server/modules/gatekeeper/index.ts index 88dccd94b..4dcca0f1d 100644 --- a/packages/server/modules/gatekeeper/index.ts +++ b/packages/server/modules/gatekeeper/index.ts @@ -18,7 +18,11 @@ const gatekeeperModule: SpeckleModule = { 'The gatekeeper module needs a valid license to run, contact Speckle to get one.' ) - if (isInitial && FF_BILLING_INTEGRATION_ENABLED) app.use(billingRouter) + if (isInitial) { + if (FF_BILLING_INTEGRATION_ENABLED) app.use(billingRouter) + // TODO: need to subscribe to the workspaceCreated event and store the workspacePlan as a trial if billing enabled, else store as unlimited + // TODO: create a cron job, that removes unused seats from the subscription at the beginning of each workspace plan's billing cycle + } } } export = gatekeeperModule From 5a80cfbbb34b2c58babde2d307835016baecdd46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Thu, 17 Oct 2024 06:54:18 +0200 Subject: [PATCH 09/48] feat(gatekeeper): add productId to the subscription domain --- .../modules/gatekeeper/domain/billing.ts | 27 ++++++++++++++++++- .../gatekeeper/domain/workspacePricing.ts | 1 - 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/server/modules/gatekeeper/domain/billing.ts b/packages/server/modules/gatekeeper/domain/billing.ts index e40500d8a..a58683889 100644 --- a/packages/server/modules/gatekeeper/domain/billing.ts +++ b/packages/server/modules/gatekeeper/domain/billing.ts @@ -74,6 +74,14 @@ 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 @@ -91,6 +99,7 @@ export type CreateCheckoutSession = (args: { export type WorkspaceSubscription = { workspaceId: string createdAt: Date + currentBillingCycleEnd: Date billingInterval: WorkspacePlanBillingIntervals subscriptionData: SubscriptionData } @@ -100,7 +109,9 @@ export type SubscriptionData = { subscriptionId: string customerId: string products: { - workspacePlan: WorkspacePricingPlans + // we're going to use the productId to match with our + productId: string + subscriptionItemId: string priceId: string quantity: number }[] @@ -109,3 +120,17 @@ export type SubscriptionData = { 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/workspacePricing.ts b/packages/server/modules/gatekeeper/domain/workspacePricing.ts index d9c9e5230..e552fe102 100644 --- a/packages/server/modules/gatekeeper/domain/workspacePricing.ts +++ b/packages/server/modules/gatekeeper/domain/workspacePricing.ts @@ -136,7 +136,6 @@ const paidWorkspacePlanFeatures: Record< team, pro, business - // unlimited } export const unpaidWorkspacePlanFeatures: Record< From 403c99ca6c28a49b361ab0f1d5aa9c3def3690c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Thu, 17 Oct 2024 07:02:35 +0200 Subject: [PATCH 10/48] feat(gatekeeper): add in memory repositories --- .../gatekeeper/repositories/billing.ts | 54 ++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/packages/server/modules/gatekeeper/repositories/billing.ts b/packages/server/modules/gatekeeper/repositories/billing.ts index a30249866..cadec054b 100644 --- a/packages/server/modules/gatekeeper/repositories/billing.ts +++ b/packages/server/modules/gatekeeper/repositories/billing.ts @@ -3,9 +3,16 @@ import { GetCheckoutSession, GetWorkspacePlan, SaveCheckoutSession, + UpdateCheckoutSessionStatus, UpsertWorkspacePlan, - WorkspacePlan + SaveWorkspaceSubscription, + WorkspaceSubscription, + WorkspacePlan, + UpsertPaidWorkspacePlan, + DeleteCheckoutSession, + GetWorkspaceCheckoutSession } from '@/modules/gatekeeper/domain/billing' +import { CheckoutSessionNotFoundError } from '@/modules/gatekeeper/errors/billing' export const getWorkspacePlanFactory = (): GetWorkspacePlan => @@ -18,7 +25,7 @@ export const getWorkspacePlanFactory = const workspacePlans: WorkspacePlan[] = [] -export const upsertWorkspacePlanFactory = +const upsertWorkspacePlanFactory = (): UpsertWorkspacePlan => ({ workspacePlan }) => { const maybePlan = workspacePlans.find( @@ -35,6 +42,11 @@ export const upsertWorkspacePlanFactory = }) } +// this is a typed rebrand of the generic workspace plan upsert +// this way TS guards the payment plan type validity +export const upsertPaidWorkspacePlanFactory = (): UpsertPaidWorkspacePlan => + upsertWorkspacePlanFactory() + const checkoutSessions: CheckoutSession[] = [] export const saveCheckoutSessionFactory = @@ -46,6 +58,12 @@ export const saveCheckoutSessionFactory = }) } +export const deleteCheckoutSessionFactory = (): DeleteCheckoutSession => () => { + return new Promise((resolve) => { + resolve() + }) +} + export const getCheckoutSessionFactory = (): GetCheckoutSession => ({ sessionId }) => { @@ -53,3 +71,35 @@ export const getCheckoutSessionFactory = resolve(checkoutSessions.find((session) => session.id === sessionId) || null) }) } + +export const getWorkspaceCheckoutSessionFactory = + (): GetWorkspaceCheckoutSession => + ({ workspaceId }) => { + return new Promise((resolve) => { + resolve( + checkoutSessions.find((session) => session.workspaceId === workspaceId) || null + ) + }) + } + +export const updateCheckoutSessionStatusFactory = + (): UpdateCheckoutSessionStatus => + ({ sessionId, paymentStatus }) => { + const session = checkoutSessions.find((session) => session.id === sessionId) + if (!session) throw new CheckoutSessionNotFoundError() + session.paymentStatus = paymentStatus + return new Promise((resolve) => { + resolve() + }) + } + +const workspaceSubscriptions: WorkspaceSubscription[] = [] + +export const saveWorkspaceSubscriptionFactory = + (): SaveWorkspaceSubscription => + ({ workspaceSubscription }) => { + workspaceSubscriptions.push(workspaceSubscription) + return new Promise((resolve) => { + resolve() + }) + } From d307a3db894cbf1fd59dfc286b57d83b5d9878f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Thu, 17 Oct 2024 07:04:03 +0200 Subject: [PATCH 11/48] feat(gatekeeper): add more errors --- packages/server/modules/gatekeeper/errors/billing.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/server/modules/gatekeeper/errors/billing.ts b/packages/server/modules/gatekeeper/errors/billing.ts index 749e3279c..e1e1ad630 100644 --- a/packages/server/modules/gatekeeper/errors/billing.ts +++ b/packages/server/modules/gatekeeper/errors/billing.ts @@ -6,8 +6,20 @@ export class WorkspacePlanNotFoundError extends BaseError { 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 +} From d7d9bce7690e574a39c49d75e8e0aa7ea5915188 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Thu, 17 Oct 2024 07:29:48 +0200 Subject: [PATCH 12/48] feat(gatekeeper): complete checkout session service --- .../modules/gatekeeper/services/workspaces.ts | 115 ++++++++++++++---- 1 file changed, 92 insertions(+), 23 deletions(-) diff --git a/packages/server/modules/gatekeeper/services/workspaces.ts b/packages/server/modules/gatekeeper/services/workspaces.ts index 4e13f990d..de5536f19 100644 --- a/packages/server/modules/gatekeeper/services/workspaces.ts +++ b/packages/server/modules/gatekeeper/services/workspaces.ts @@ -3,24 +3,33 @@ import { CreateCheckoutSession, GetCheckoutSession, GetWorkspacePlan, - SessionInput, - SaveCheckoutSession + SaveCheckoutSession, + UpdateCheckoutSessionStatus, + SaveWorkspaceSubscription, + UpsertPaidWorkspacePlan, + GetSubscriptionData, + GetWorkspaceCheckoutSession } from '@/modules/gatekeeper/domain/billing' import { - WorkspacePlanBillingIntervals, - WorkspacePlans + PaidWorkspacePlans, + WorkspacePlanBillingIntervals } from '@/modules/gatekeeper/domain/workspacePricing' -import { WorkspaceAlreadyPaidError } from '@/modules/gatekeeper/errors/billing' +import { + 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 @@ -34,7 +43,7 @@ export const startCheckoutSessionFactory = }: { workspaceId: string workspaceSlug: string - workspacePlan: WorkspacePlans + workspacePlan: PaidWorkspacePlans billingInterval: WorkspacePlanBillingIntervals }): Promise => { // get workspace plan, if we're already on a paid plan, do not allow checkout @@ -55,9 +64,14 @@ export const startCheckoutSessionFactory = // lets go ahead and pay break default: - throwUncoveredError(existingWorkspacePlan.status) + 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 }), @@ -79,23 +93,78 @@ export const startCheckoutSessionFactory = } export const completeCheckoutSessionFactory = - ({ getCheckoutSession }: { getCheckoutSession: GetCheckoutSession }) => - async ({ session }: { session: SessionInput }): Promise => { - const checkoutSession = await getCheckoutSession({ sessionId: session.id }) - if (!checkoutSession && session.paymentStatus === 'paid') + ({ + 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 Error('checkout session is not found this is a bo bo') - // idk what to do here, if there is no checkout session, it prob fine, could be a replay etc - // but the more schematically correct thing would be, to throw an error - if (!checkoutSession) return - // if statuses match, nothing to do - if (session.paymentStatus === checkoutSession.paymentStatus) return - - // update checkout session, to have the input payment status - // prob in this case, we should not be allowing a to move a paid checkout session to paid - - if (session.paymentStatus === 'paid') { - // move workspace to the plan, and payment status valid - // save the workspace subscription information in the DB + switch (checkoutSession.paymentStatus) { + case 'paid': + // if the session is already paid, we do not need to provision anything + return + 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(), + currentBillingCycleEnd, + workspaceId: checkoutSession.workspaceId, + billingInterval: checkoutSession.billingInterval, + subscriptionData + } + + await saveWorkspaceSubscription({ + workspaceSubscription + }) } From 6ae4b5d2da79180923ebb1eb11b1c13f8261ce63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Thu, 17 Oct 2024 07:30:31 +0200 Subject: [PATCH 13/48] feat(gatekeeper): add stripe client implementation --- .../modules/gatekeeper/clients/stripe.ts | 88 ++++++++++++++++++- 1 file changed, 84 insertions(+), 4 deletions(-) diff --git a/packages/server/modules/gatekeeper/clients/stripe.ts b/packages/server/modules/gatekeeper/clients/stripe.ts index 19b55592a..3416a29e5 100644 --- a/packages/server/modules/gatekeeper/clients/stripe.ts +++ b/packages/server/modules/gatekeeper/clients/stripe.ts @@ -1,4 +1,9 @@ -import { CreateCheckoutSession } from '@/modules/gatekeeper/domain/billing' +/* eslint-disable camelcase */ +import { + CreateCheckoutSession, + GetSubscriptionData, + WorkspaceSubscription +} from '@/modules/gatekeeper/domain/billing' import { WorkspacePlanBillingIntervals, WorkspacePricingPlans @@ -48,11 +53,11 @@ export const createCheckoutSessionFactory = const session = await stripe.checkout.sessions.create({ mode: 'subscription', - // eslint-disable-next-line camelcase + line_items: costLineItems, - // eslint-disable-next-line camelcase + success_url: `${resultUrl.toString()}&payment_status=success&session_id={CHECKOUT_SESSION_ID}`, - // eslint-disable-next-line camelcase + cancel_url: `${resultUrl.toString()}&payment_status=cancelled&session_id={CHECKOUT_SESSION_ID}` }) @@ -66,3 +71,78 @@ export const createCheckoutSessionFactory = 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' } + ) + } From e7bfa387e89c94b7705eae2cf40778317490b85c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Thu, 17 Oct 2024 07:31:34 +0200 Subject: [PATCH 14/48] feat(gatekeeper): add checkout session completion webhook callback path --- .../server/modules/gatekeeper/rest/billing.ts | 146 ++++++++++-------- 1 file changed, 82 insertions(+), 64 deletions(-) diff --git a/packages/server/modules/gatekeeper/rest/billing.ts b/packages/server/modules/gatekeeper/rest/billing.ts index d1cafa181..1fdb7af93 100644 --- a/packages/server/modules/gatekeeper/rest/billing.ts +++ b/packages/server/modules/gatekeeper/rest/billing.ts @@ -2,20 +2,17 @@ import { Router } from 'express' import { validateRequest } from 'zod-express' import { z } from 'zod' import { authorizeResolver } from '@/modules/shared' -import { ensureError, Roles, throwUncoveredError } from '@speckle/shared' +import { ensureError, Roles } from '@speckle/shared' import { Stripe } from 'stripe' import { getFrontendOrigin, + getStringFromEnv, getStripeApiKey, - getStripeEndpointSigningKey, - getWorkspaceBusinessSeatStripePriceId, - getWorkspaceGuestSeatStripePriceId, - getWorkspaceProSeatStripePriceId, - getWorkspaceTeamSeatStripePriceId + getStripeEndpointSigningKey } from '@/modules/shared/helpers/envHelper' import { WorkspacePlanBillingIntervals, - workspacePlans, + paidWorkspacePlans, WorkspacePricingPlans } from '@/modules/gatekeeper/domain/workspacePricing' import { @@ -23,13 +20,26 @@ import { getWorkspaceBySlugFactory } from '@/modules/workspaces/repositories/workspaces' import { db } from '@/db/knex' -import { startCheckoutSessionFactory } from '@/modules/gatekeeper/services/workspaces' -import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace' -import { createCheckoutSessionFactory } from '@/modules/gatekeeper/clients/stripe' import { + completeCheckoutSessionFactory, + startCheckoutSessionFactory +} from '@/modules/gatekeeper/services/workspaces' +import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace' +import { + createCheckoutSessionFactory, + getSubscriptionDataFactory +} from '@/modules/gatekeeper/clients/stripe' +import { + deleteCheckoutSessionFactory, + getCheckoutSessionFactory, + getWorkspaceCheckoutSessionFactory, getWorkspacePlanFactory, - saveCheckoutSessionFactory + saveCheckoutSessionFactory, + saveWorkspaceSubscriptionFactory, + updateCheckoutSessionStatusFactory, + upsertPaidWorkspacePlanFactory } from '@/modules/gatekeeper/repositories/billing' +import { GetWorkspacePlanPrice } from '@/modules/gatekeeper/domain/billing' const router = Router() @@ -37,33 +47,43 @@ export default router const stripe = new Stripe(getStripeApiKey(), { typescript: true }) -const getWorkspacePlanPrice = ({ - workspacePlan -}: { - workspacePlan: WorkspacePricingPlans - billingInterval: WorkspacePlanBillingIntervals -}): string => { - // right now, ignoring interval - switch (workspacePlan) { - case 'team': - return getWorkspaceTeamSeatStripePriceId() - case 'pro': - return getWorkspaceProSeatStripePriceId() - case 'business': - return getWorkspaceBusinessSeatStripePriceId() - case 'guest': - return getWorkspaceGuestSeatStripePriceId() - default: - throwUncoveredError(workspacePlan) +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] router.get( '/api/v1/billing/workspaces/:workspaceSlug/checkout-session/:workspacePlan', validateRequest({ params: z.object({ workspaceSlug: z.string().min(1), - workspacePlan: workspacePlans + workspacePlan: paidWorkspacePlans }) }), async (req) => { @@ -88,6 +108,7 @@ router.get( const countRole = countWorkspaceRoleWithOptionalProjectRoleFactory({ db }) const session = await startCheckoutSessionFactory({ + getWorkspaceCheckoutSession: getWorkspaceCheckoutSessionFactory(), getWorkspacePlan: getWorkspacePlanFactory(), countRole, createCheckoutSession, @@ -98,34 +119,6 @@ router.get( } ) -// const fulfillCheckoutFactory = -// ({ stripe }: { stripe: Stripe }) => -// async ({ sessionId }: { sessionId: string }) => { -// // Set your secret key. Remember to switch to your live secret key in production. -// // See your keys here: https://dashboard.stripe.com/apikeys - -// console.log('Fulfilling Checkout Session ' + sessionId) - -// // TODO: Make this function safe to run multiple times, -// // even concurrently, with the same session ID - -// // TODO: Make sure fulfillment hasn't already been -// // peformed for this Checkout Session - -// // Retrieve the Checkout Session from the API with line_items expanded -// const checkoutSession = await stripe.checkout.sessions.retrieve(sessionId, { -// expand: ['line_items'] -// }) - -// // Check the Checkout Session's payment_status property -// // to determine if fulfillment should be peformed -// if (checkoutSession.payment_status !== 'unpaid') { -// // TODO: Perform fulfillment of the line items -// // TODO: Record/save fulfillment status for this -// // Checkout Session -// } -// } - router.post('/api/v1/billing/webhooks', async (req, res) => { const endpointSecret = getStripeEndpointSigningKey() const sig = req.headers['stripe-signature'] @@ -156,9 +149,10 @@ router.post('/api/v1/billing/webhooks', async (req, res) => { case 'checkout.session.completed': const session = event.data.object - session.subscription + if (!session.subscription) + return res.status(400).send('We only support subscription type checkouts') - if (session.payment_status !== 'unpaid') { + 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! @@ -167,11 +161,35 @@ router.post('/api/v1/billing/webhooks', async (req, res) => { // set checkout state to paid // go ahead and provision the plan // store customer id and subscription Id associated to the workspace plan - } - // move the workspace plan to the new plan + const subscriptionId = + typeof session.subscription === 'string' + ? session.subscription + : session.subscription.id + + // this must use a transaction + const completeCheckout = completeCheckoutSessionFactory({ + getCheckoutSession: getCheckoutSessionFactory(), + updateCheckoutSessionStatus: updateCheckoutSessionStatusFactory(), + upsertPaidWorkspacePlan: upsertPaidWorkspacePlanFactory(), + saveWorkspaceSubscription: saveWorkspaceSubscriptionFactory(), + getSubscriptionData: getSubscriptionDataFactory({ + stripe + }) + }) + + await completeCheckout({ + sessionId: session.id, + subscriptionId + }) + } + break + case 'checkout.session.expired': - // delete the checkout session from the DB + // delete the checkout session from the DB + await deleteCheckoutSessionFactory()({ checkoutSessionId: event.data.object.id }) + break + default: break } From 1ceca7369a2f0e397fab48d5849135b49e756e7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Thu, 17 Oct 2024 12:10:25 +0200 Subject: [PATCH 15/48] feat(gendo): fix not needing env vars if gendo module is not enabled --- packages/server/modules/gendo/index.ts | 9 +++++++-- packages/shared/src/environment/index.ts | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/server/modules/gendo/index.ts b/packages/server/modules/gendo/index.ts index 5f2a75a03..5ba157db9 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/shared/src/environment/index.ts b/packages/shared/src/environment/index.ts index e4f780036..e7a228849 100644 --- a/packages/shared/src/environment/index.ts +++ b/packages/shared/src/environment/index.ts @@ -13,7 +13,7 @@ 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: { From 49128192deb003665fbac7f6fbe3f7d90ddc04aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Thu, 17 Oct 2024 12:11:22 +0200 Subject: [PATCH 16/48] feat(gatekeeper): require a license for billing --- packages/server/modules/gatekeeper/domain/types.ts | 11 +++++++---- packages/server/modules/gatekeeper/index.ts | 14 ++++++++++++-- .../gatekeeper/tests/validateLicense.spec.ts | 2 +- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/server/modules/gatekeeper/domain/types.ts b/packages/server/modules/gatekeeper/domain/types.ts index 82250cd59..044c63c11 100644 --- a/packages/server/modules/gatekeeper/domain/types.ts +++ b/packages/server/modules/gatekeeper/domain/types.ts @@ -1,9 +1,12 @@ import { z } from 'zod' -const EnabledModules = z.object({ - workspaces: z.boolean(), - gatekeeper: 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/index.ts b/packages/server/modules/gatekeeper/index.ts index 4dcca0f1d..d90f26979 100644 --- a/packages/server/modules/gatekeeper/index.ts +++ b/packages/server/modules/gatekeeper/index.ts @@ -19,9 +19,19 @@ const gatekeeperModule: SpeckleModule = { ) if (isInitial) { - if (FF_BILLING_INTEGRATION_ENABLED) app.use(billingRouter) // TODO: need to subscribe to the workspaceCreated event and store the workspacePlan as a trial if billing enabled, else store as unlimited - // TODO: create a cron job, that removes unused seats from the subscription at the beginning of each workspace plan's billing cycle + 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 + } } } } diff --git a/packages/server/modules/gatekeeper/tests/validateLicense.spec.ts b/packages/server/modules/gatekeeper/tests/validateLicense.spec.ts index d0dd49ca8..5d22a8f58 100644 --- a/packages/server/modules/gatekeeper/tests/validateLicense.spec.ts +++ b/packages/server/modules/gatekeeper/tests/validateLicense.spec.ts @@ -123,7 +123,7 @@ describe('validateLicense @gatekeeper', () => { licenseToken, canonicalUrl, publicKey, - requiredModules: ['workspaces'] + requiredModules: ['workspaces', 'gatekeeper'] }) expect(result).to.be.false From 2ba5e75474b1a0ed0bcfdca3eaaf527c0fbd2da8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Thu, 17 Oct 2024 14:19:30 +0200 Subject: [PATCH 17/48] chore(gatekeeper): cleanup before testing --- packages/server/modules/gatekeeper/rest/billing.ts | 2 +- .../modules/gatekeeper/services/{workspaces.ts => checkout.ts} | 0 .../modules/gatekeeper/tests/{ => unit}/validateLicense.spec.ts | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename packages/server/modules/gatekeeper/services/{workspaces.ts => checkout.ts} (100%) rename packages/server/modules/gatekeeper/tests/{ => unit}/validateLicense.spec.ts (100%) diff --git a/packages/server/modules/gatekeeper/rest/billing.ts b/packages/server/modules/gatekeeper/rest/billing.ts index 1fdb7af93..f33a04753 100644 --- a/packages/server/modules/gatekeeper/rest/billing.ts +++ b/packages/server/modules/gatekeeper/rest/billing.ts @@ -23,7 +23,7 @@ import { db } from '@/db/knex' import { completeCheckoutSessionFactory, startCheckoutSessionFactory -} from '@/modules/gatekeeper/services/workspaces' +} from '@/modules/gatekeeper/services/checkout' import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace' import { createCheckoutSessionFactory, diff --git a/packages/server/modules/gatekeeper/services/workspaces.ts b/packages/server/modules/gatekeeper/services/checkout.ts similarity index 100% rename from packages/server/modules/gatekeeper/services/workspaces.ts rename to packages/server/modules/gatekeeper/services/checkout.ts diff --git a/packages/server/modules/gatekeeper/tests/validateLicense.spec.ts b/packages/server/modules/gatekeeper/tests/unit/validateLicense.spec.ts similarity index 100% rename from packages/server/modules/gatekeeper/tests/validateLicense.spec.ts rename to packages/server/modules/gatekeeper/tests/unit/validateLicense.spec.ts From 9118f1aa33c1b691c0814e4c74f8eb43fbf09171 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Thu, 17 Oct 2024 14:35:19 +0200 Subject: [PATCH 18/48] feat(gatekeeper): subscriptionData parsing model --- .../modules/gatekeeper/domain/billing.ts | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/server/modules/gatekeeper/domain/billing.ts b/packages/server/modules/gatekeeper/domain/billing.ts index a58683889..6398a94a8 100644 --- a/packages/server/modules/gatekeeper/domain/billing.ts +++ b/packages/server/modules/gatekeeper/domain/billing.ts @@ -5,6 +5,7 @@ import { WorkspacePlanBillingIntervals, WorkspacePricingPlans } from '@/modules/gatekeeper/domain/workspacePricing' +import { z } from 'zod' export type UnpaidWorkspacePlanStatuses = 'valid' @@ -104,18 +105,22 @@ export type WorkspaceSubscription = { 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 = { - subscriptionId: string - customerId: string - products: { - // we're going to use the productId to match with our - productId: string - subscriptionItemId: string - priceId: string - quantity: number - }[] -} +export type SubscriptionData = z.infer export type SaveWorkspaceSubscription = (args: { workspaceSubscription: WorkspaceSubscription From 96e127c0602bb2901161598233e05e142c9f8fba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Thu, 17 Oct 2024 19:17:41 +0200 Subject: [PATCH 19/48] ci: add billing integration and gatekeeper modules to test config --- .circleci/config.yml | 3 +++ packages/server/modules/shared/helpers/envHelper.ts | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 29d7191e7..1f9264409 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -467,6 +467,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: @@ -557,6 +558,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/server/modules/shared/helpers/envHelper.ts b/packages/server/modules/shared/helpers/envHelper.ts index 7ac50cad4..45ec78aef 100644 --- a/packages/server/modules/shared/helpers/envHelper.ts +++ b/packages/server/modules/shared/helpers/envHelper.ts @@ -52,10 +52,11 @@ export function getBooleanFromEnv(envVarKey: string, aDefault = false): boolean } export function getStringFromEnv(envVarKey: string): string { - if (!process.env[envVarKey]) { + const envVar = process.env[envVarKey] + if (!envVar) { throw new MisconfiguredEnvironmentError(`${envVarKey} env var not configured`) } - return process.env[envVarKey] + return envVar } /** From 7bb99df3bf9810aff8e44db5ad5c7d323c35a7c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Fri, 18 Oct 2024 12:03:51 +0200 Subject: [PATCH 20/48] test(gatekeeper): add checkout service tests --- .../modules/gatekeeper/services/checkout.ts | 7 +- .../gatekeeper/tests/unit/checkout.spec.ts | 316 ++++++++++++++++++ 2 files changed, 320 insertions(+), 3 deletions(-) create mode 100644 packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts diff --git a/packages/server/modules/gatekeeper/services/checkout.ts b/packages/server/modules/gatekeeper/services/checkout.ts index de5536f19..283f53c02 100644 --- a/packages/server/modules/gatekeeper/services/checkout.ts +++ b/packages/server/modules/gatekeeper/services/checkout.ts @@ -15,6 +15,7 @@ import { WorkspacePlanBillingIntervals } from '@/modules/gatekeeper/domain/workspacePricing' import { + CheckoutSessionNotFoundError, WorkspaceAlreadyPaidError, WorkspaceCheckoutSessionInProgressError } from '@/modules/gatekeeper/errors/billing' @@ -49,6 +50,7 @@ export const startCheckoutSessionFactory = // 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) { @@ -117,13 +119,12 @@ export const completeCheckoutSessionFactory = subscriptionId: string }): Promise => { const checkoutSession = await getCheckoutSession({ sessionId }) - if (!checkoutSession) - throw new Error('checkout session is not found this is a bo bo') + if (!checkoutSession) throw new CheckoutSessionNotFoundError() switch (checkoutSession.paymentStatus) { case 'paid': // if the session is already paid, we do not need to provision anything - return + throw new WorkspaceAlreadyPaidError() case 'unpaid': break default: 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 000000000..7e62b8765 --- /dev/null +++ b/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts @@ -0,0 +1,316 @@ +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' + }), + 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' + } + 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' + } + 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' + }), + 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' + } + + 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) + }) + }) + }) +}) From 81d09dd07cb8023170882181efc1202ff24ffd09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Fri, 18 Oct 2024 12:39:51 +0200 Subject: [PATCH 21/48] feat(gatekeeper): make completeCheckout callback idempotent properly --- .../server/modules/gatekeeper/rest/billing.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/server/modules/gatekeeper/rest/billing.ts b/packages/server/modules/gatekeeper/rest/billing.ts index f33a04753..b4752f0e5 100644 --- a/packages/server/modules/gatekeeper/rest/billing.ts +++ b/packages/server/modules/gatekeeper/rest/billing.ts @@ -40,6 +40,7 @@ import { upsertPaidWorkspacePlanFactory } from '@/modules/gatekeeper/repositories/billing' import { GetWorkspacePlanPrice } from '@/modules/gatekeeper/domain/billing' +import { WorkspaceAlreadyPaidError } from '@/modules/gatekeeper/errors/billing' const router = Router() @@ -178,10 +179,18 @@ router.post('/api/v1/billing/webhooks', async (req, res) => { }) }) - await completeCheckout({ - sessionId: session.id, - subscriptionId - }) + try { + await completeCheckout({ + sessionId: session.id, + subscriptionId + }) + } catch (err) { + if (err instanceof WorkspaceAlreadyPaidError) { + // ignore the request, this is prob a replay from stripe + } else { + throw err + } + } } break From cf5cf4b9c09252ebe4e1476f4e627589c703f500 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Sat, 19 Oct 2024 14:58:02 +0200 Subject: [PATCH 22/48] feat(gatekeeper): move to knex based repositories --- .../modules/gatekeeper/clients/stripe.ts | 2 + .../modules/gatekeeper/domain/billing.ts | 14 +- packages/server/modules/gatekeeper/index.ts | 3 + .../20241018132400_workspace_checkout.ts | 41 ++++++ .../gatekeeper/repositories/billing.ts | 128 +++++++++--------- .../server/modules/gatekeeper/rest/billing.ts | 53 +++++--- .../modules/gatekeeper/services/checkout.ts | 1 + 7 files changed, 150 insertions(+), 92 deletions(-) create mode 100644 packages/server/modules/gatekeeper/migrations/20241018132400_workspace_checkout.ts diff --git a/packages/server/modules/gatekeeper/clients/stripe.ts b/packages/server/modules/gatekeeper/clients/stripe.ts index 3416a29e5..2bd749cfd 100644 --- a/packages/server/modules/gatekeeper/clients/stripe.ts +++ b/packages/server/modules/gatekeeper/clients/stripe.ts @@ -68,6 +68,8 @@ export const createCheckoutSessionFactory = billingInterval, workspacePlan, workspaceId, + createdAt: new Date(), + updatedAt: new Date(), paymentStatus: 'unpaid' } } diff --git a/packages/server/modules/gatekeeper/domain/billing.ts b/packages/server/modules/gatekeeper/domain/billing.ts index 6398a94a8..0d3ab11cf 100644 --- a/packages/server/modules/gatekeeper/domain/billing.ts +++ b/packages/server/modules/gatekeeper/domain/billing.ts @@ -17,20 +17,21 @@ export type PaidWorkspacePlanStatuses = export type TrialWorkspacePlanStatuses = 'trial' -export type PaidWorkspacePlan = { +type BaseWorkspacePlan = { workspaceId: string +} + +export type PaidWorkspacePlan = BaseWorkspacePlan & { name: PaidWorkspacePlans status: PaidWorkspacePlanStatuses } -export type TrialWorkspacePlan = { - workspaceId: string +export type TrialWorkspacePlan = BaseWorkspacePlan & { name: TrialWorkspacePlans status: TrialWorkspacePlanStatuses } -export type UnpaidWorkspacePlan = { - workspaceId: string +export type UnpaidWorkspacePlan = BaseWorkspacePlan & { name: UnpaidWorkspacePlans status: UnpaidWorkspacePlanStatuses } @@ -65,6 +66,8 @@ export type CheckoutSession = SessionInput & { workspacePlan: PaidWorkspacePlans paymentStatus: SessionPaymentStatus billingInterval: WorkspacePlanBillingIntervals + createdAt: Date + updatedAt: Date } export type SaveCheckoutSession = (args: { @@ -100,6 +103,7 @@ export type CreateCheckoutSession = (args: { export type WorkspaceSubscription = { workspaceId: string createdAt: Date + updatedAt: Date currentBillingCycleEnd: Date billingInterval: WorkspacePlanBillingIntervals subscriptionData: SubscriptionData diff --git a/packages/server/modules/gatekeeper/index.ts b/packages/server/modules/gatekeeper/index.ts index d90f26979..5e37d9315 100644 --- a/packages/server/modules/gatekeeper/index.ts +++ b/packages/server/modules/gatekeeper/index.ts @@ -1,3 +1,4 @@ +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' @@ -18,6 +19,8 @@ const gatekeeperModule: SpeckleModule = { '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) { 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 000000000..da222e7bf --- /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 index cadec054b..93d7fa9bc 100644 --- a/packages/server/modules/gatekeeper/repositories/billing.ts +++ b/packages/server/modules/gatekeeper/repositories/billing.ts @@ -12,94 +12,90 @@ import { DeleteCheckoutSession, GetWorkspaceCheckoutSession } from '@/modules/gatekeeper/domain/billing' -import { CheckoutSessionNotFoundError } from '@/modules/gatekeeper/errors/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 = - (): GetWorkspacePlan => - ({ workspaceId }) => { - const maybePlan = workspacePlans.find((plan) => plan.workspaceId === workspaceId) - return new Promise((resolve) => { - resolve(maybePlan || null) - }) + ({ db }: { db: Knex }): GetWorkspacePlan => + async ({ workspaceId }) => { + const workspacePlan = await tables + .workspacePlans(db) + .select() + .where({ workspaceId }) + .first() + return workspacePlan ?? null } -const workspacePlans: WorkspacePlan[] = [] - const upsertWorkspacePlanFactory = - (): UpsertWorkspacePlan => - ({ workspacePlan }) => { - const maybePlan = workspacePlans.find( - (plan) => plan.workspaceId === workspacePlan.workspaceId - ) - if (maybePlan) { - maybePlan.name = workspacePlan.name - maybePlan.status = workspacePlan.status - } else { - workspacePlans.push(workspacePlan) - } - return new Promise((resolve) => { - resolve() - }) + ({ 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 = (): UpsertPaidWorkspacePlan => - upsertWorkspacePlanFactory() - -const checkoutSessions: CheckoutSession[] = [] +export const upsertPaidWorkspacePlanFactory = ({ + db +}: { + db: Knex +}): UpsertPaidWorkspacePlan => upsertWorkspacePlanFactory({ db }) export const saveCheckoutSessionFactory = - (): SaveCheckoutSession => - ({ checkoutSession }) => { - checkoutSessions.push(checkoutSession) - return new Promise((resolve) => { - resolve() - }) + ({ db }: { db: Knex }): SaveCheckoutSession => + async ({ checkoutSession }) => { + await tables.workspaceCheckoutSessions(db).insert(checkoutSession) } -export const deleteCheckoutSessionFactory = (): DeleteCheckoutSession => () => { - return new Promise((resolve) => { - resolve() - }) -} +export const deleteCheckoutSessionFactory = + ({ db }: { db: Knex }): DeleteCheckoutSession => + async ({ checkoutSessionId }) => { + await tables.workspaceCheckoutSessions(db).delete().where({ id: checkoutSessionId }) + } export const getCheckoutSessionFactory = - (): GetCheckoutSession => - ({ sessionId }) => { - return new Promise((resolve) => { - resolve(checkoutSessions.find((session) => session.id === sessionId) || null) - }) + ({ db }: { db: Knex }): GetCheckoutSession => + async ({ sessionId }) => { + const checkoutSession = await tables + .workspaceCheckoutSessions(db) + .select() + .where({ id: sessionId }) + .first() + return checkoutSession || null } export const getWorkspaceCheckoutSessionFactory = - (): GetWorkspaceCheckoutSession => - ({ workspaceId }) => { - return new Promise((resolve) => { - resolve( - checkoutSessions.find((session) => session.workspaceId === workspaceId) || null - ) - }) + ({ db }: { db: Knex }): GetWorkspaceCheckoutSession => + async ({ workspaceId }) => { + const checkoutSession = await tables + .workspaceCheckoutSessions(db) + .select() + .where({ workspaceId }) + .first() + return checkoutSession || null } export const updateCheckoutSessionStatusFactory = - (): UpdateCheckoutSessionStatus => - ({ sessionId, paymentStatus }) => { - const session = checkoutSessions.find((session) => session.id === sessionId) - if (!session) throw new CheckoutSessionNotFoundError() - session.paymentStatus = paymentStatus - return new Promise((resolve) => { - resolve() - }) + ({ db }: { db: Knex }): UpdateCheckoutSessionStatus => + async ({ sessionId, paymentStatus }) => { + await tables + .workspaceCheckoutSessions(db) + .where({ id: sessionId }) + .update({ paymentStatus, updatedAt: new Date() }) } -const workspaceSubscriptions: WorkspaceSubscription[] = [] - export const saveWorkspaceSubscriptionFactory = - (): SaveWorkspaceSubscription => - ({ workspaceSubscription }) => { - workspaceSubscriptions.push(workspaceSubscription) - return new Promise((resolve) => { - resolve() - }) + ({ 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 index b4752f0e5..26d0bd1b2 100644 --- a/packages/server/modules/gatekeeper/rest/billing.ts +++ b/packages/server/modules/gatekeeper/rest/billing.ts @@ -13,11 +13,12 @@ import { import { WorkspacePlanBillingIntervals, paidWorkspacePlans, - WorkspacePricingPlans + WorkspacePricingPlans, + workspacePlanBillingIntervals } from '@/modules/gatekeeper/domain/workspacePricing' import { countWorkspaceRoleWithOptionalProjectRoleFactory, - getWorkspaceBySlugFactory + getWorkspaceFactory } from '@/modules/workspaces/repositories/workspaces' import { db } from '@/db/knex' import { @@ -41,6 +42,7 @@ import { } 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() @@ -80,19 +82,20 @@ const getWorkspacePlanPrice: GetWorkspacePlanPrice = ({ }) => workspacePlanPrices()[workspacePlan][billingInterval] router.get( - '/api/v1/billing/workspaces/:workspaceSlug/checkout-session/:workspacePlan', + '/api/v1/billing/workspaces/:workspaceId/checkout-session/:workspacePlan/:billingInterval', validateRequest({ params: z.object({ - workspaceSlug: z.string().min(1), - workspacePlan: paidWorkspacePlans + workspaceId: z.string().min(1), + workspacePlan: paidWorkspacePlans, + billingInterval: workspacePlanBillingIntervals }) }), async (req) => { - const { workspaceSlug, workspacePlan } = req.params - const workspace = await getWorkspaceBySlugFactory({ db })({ workspaceSlug }) + const { workspaceId, workspacePlan, billingInterval } = req.params + const workspace = await getWorkspaceFactory({ db })({ workspaceId }) + if (!workspace) throw new WorkspaceNotFoundError() - const workspaceId = workspace.id await authorizeResolver( req.context.userId, workspaceId, @@ -109,12 +112,12 @@ router.get( const countRole = countWorkspaceRoleWithOptionalProjectRoleFactory({ db }) const session = await startCheckoutSessionFactory({ - getWorkspaceCheckoutSession: getWorkspaceCheckoutSessionFactory(), - getWorkspacePlan: getWorkspacePlanFactory(), + getWorkspaceCheckoutSession: getWorkspaceCheckoutSessionFactory({ db }), + getWorkspacePlan: getWorkspacePlanFactory({ db }), countRole, createCheckoutSession, - saveCheckoutSession: saveCheckoutSessionFactory() - })({ workspacePlan, workspaceId, workspaceSlug, billingInterval: 'monthly' }) + saveCheckoutSession: saveCheckoutSessionFactory({ db }) + })({ workspacePlan, workspaceId, workspaceSlug: workspace.slug, billingInterval }) req.res?.redirect(session.url) } @@ -169,21 +172,27 @@ router.post('/api/v1/billing/webhooks', async (req, res) => { : session.subscription.id // this must use a transaction + + const trx = await db.transaction() + const completeCheckout = completeCheckoutSessionFactory({ - getCheckoutSession: getCheckoutSessionFactory(), - updateCheckoutSessionStatus: updateCheckoutSessionStatusFactory(), - upsertPaidWorkspacePlan: upsertPaidWorkspacePlanFactory(), - saveWorkspaceSubscription: saveWorkspaceSubscriptionFactory(), + getCheckoutSession: getCheckoutSessionFactory({ db: trx }), + updateCheckoutSessionStatus: updateCheckoutSessionStatusFactory({ db: trx }), + upsertPaidWorkspacePlan: upsertPaidWorkspacePlanFactory({ db: trx }), + saveWorkspaceSubscription: saveWorkspaceSubscriptionFactory({ db: trx }), getSubscriptionData: getSubscriptionDataFactory({ stripe }) }) try { - await completeCheckout({ - sessionId: session.id, - subscriptionId - }) + await withTransaction( + completeCheckout({ + sessionId: session.id, + subscriptionId + }), + trx + ) } catch (err) { if (err instanceof WorkspaceAlreadyPaidError) { // ignore the request, this is prob a replay from stripe @@ -196,7 +205,9 @@ router.post('/api/v1/billing/webhooks', async (req, res) => { case 'checkout.session.expired': // delete the checkout session from the DB - await deleteCheckoutSessionFactory()({ checkoutSessionId: event.data.object.id }) + await deleteCheckoutSessionFactory({ db })({ + checkoutSessionId: event.data.object.id + }) break default: diff --git a/packages/server/modules/gatekeeper/services/checkout.ts b/packages/server/modules/gatekeeper/services/checkout.ts index 283f53c02..b9a9e7611 100644 --- a/packages/server/modules/gatekeeper/services/checkout.ts +++ b/packages/server/modules/gatekeeper/services/checkout.ts @@ -159,6 +159,7 @@ export const completeCheckoutSessionFactory = const workspaceSubscription = { createdAt: new Date(), + updatedAt: new Date(), currentBillingCycleEnd, workspaceId: checkoutSession.workspaceId, billingInterval: checkoutSession.billingInterval, From 4770aaf83f1b921557f5060016ab01930afe4679 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Sat, 19 Oct 2024 14:59:18 +0200 Subject: [PATCH 23/48] test(gatekeeper): billing repository tests --- .../intergration/billingRepositories.spec.ts | 221 ++++++++++++++++++ .../gatekeeper/tests/unit/checkout.spec.ts | 20 +- .../tests/integration/repositories.spec.ts | 30 +-- .../server/test/speckle-helpers/workspaces.ts | 27 +++ 4 files changed, 269 insertions(+), 29 deletions(-) create mode 100644 packages/server/modules/gatekeeper/tests/intergration/billingRepositories.spec.ts create mode 100644 packages/server/test/speckle-helpers/workspaces.ts 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 000000000..351c3f656 --- /dev/null +++ b/packages/server/modules/gatekeeper/tests/intergration/billingRepositories.spec.ts @@ -0,0 +1,221 @@ +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') + expect( + storedSession!.updatedAt.getTime() - checkoutSession.updatedAt.getTime() > 0 + ).to.be.true + }) + }) + 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 index 7e62b8765..dc437de48 100644 --- a/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts +++ b/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts @@ -98,7 +98,9 @@ describe('checkout @gatekeeper', () => { paymentStatus: 'unpaid', url: '', workspaceId, - workspacePlan: 'business' + workspacePlan: 'business', + createdAt: new Date(), + updatedAt: new Date() }), countRole: () => { expect.fail() @@ -130,7 +132,9 @@ describe('checkout @gatekeeper', () => { workspacePlan, url: 'https://example.com', billingInterval, - paymentStatus: 'unpaid' + paymentStatus: 'unpaid', + createdAt: new Date(), + updatedAt: new Date() } let storedCheckoutSession: CheckoutSession | undefined = undefined const createdCheckoutSession = await startCheckoutSessionFactory({ @@ -160,7 +164,9 @@ describe('checkout @gatekeeper', () => { workspacePlan, url: 'https://example.com', billingInterval, - paymentStatus: 'unpaid' + paymentStatus: 'unpaid', + createdAt: new Date(), + updatedAt: new Date() } let storedCheckoutSession: CheckoutSession | undefined = undefined const createdCheckoutSession = await startCheckoutSessionFactory({ @@ -217,7 +223,9 @@ describe('checkout @gatekeeper', () => { paymentStatus: 'paid', url: 'https://example.com', workspaceId: cryptoRandomString({ length: 10 }), - workspacePlan: 'business' + workspacePlan: 'business', + createdAt: new Date(), + updatedAt: new Date() }), updateCheckoutSessionStatus: async () => { expect.fail() @@ -247,7 +255,9 @@ describe('checkout @gatekeeper', () => { paymentStatus: 'unpaid', url: 'https://example.com', workspaceId, - workspacePlan: 'business' + workspacePlan: 'business', + createdAt: new Date(), + updatedAt: new Date() } let storedWorkspacePlan: PaidWorkspacePlan | undefined = undefined diff --git a/packages/server/modules/workspaces/tests/integration/repositories.spec.ts b/packages/server/modules/workspaces/tests/integration/repositories.spec.ts index a05d68848..9b3da7b38 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 }) @@ -59,6 +59,11 @@ const updateUserEmail = updateUserEmailFactory({ db }) const getUserDiscoverableWorkspaces = getUserDiscoverableWorkspacesFactory({ db }) const upsertProjectRole = upsertProjectRoleFactory({ db }) +const upsertWorkspace = upsertWorkspaceFactory({ db }) +const createAndStoreTestWorkspace = createAndStoreTestWorkspaceFactory({ + upsertWorkspace +}) + const createAndStoreTestUser = async (): Promise => { const testId = cryptoRandomString({ length: 6 }) @@ -75,29 +80,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/test/speckle-helpers/workspaces.ts b/packages/server/test/speckle-helpers/workspaces.ts new file mode 100644 index 000000000..3f86700f2 --- /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 + } From 8e923692c5c6a07a87c7064e07278d25bfef041b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Sat, 19 Oct 2024 15:30:59 +0200 Subject: [PATCH 24/48] feat(gatekeeper): add yearly billing cycle toggle --- .../settings/workspaces/Billing.vue | 36 +++++++++++++++++++ .../server/modules/gatekeeper/rest/billing.ts | 1 + 2 files changed, 37 insertions(+) diff --git a/packages/frontend-2/components/settings/workspaces/Billing.vue b/packages/frontend-2/components/settings/workspaces/Billing.vue index 16b57ce53..bf138d79d 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 +
+
@@ -86,6 +105,23 @@ const props = defineProps<{ workspaceId: string }>() +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/server/modules/gatekeeper/rest/billing.ts b/packages/server/modules/gatekeeper/rest/billing.ts index 26d0bd1b2..da2a815f9 100644 --- a/packages/server/modules/gatekeeper/rest/billing.ts +++ b/packages/server/modules/gatekeeper/rest/billing.ts @@ -81,6 +81,7 @@ const getWorkspacePlanPrice: GetWorkspacePlanPrice = ({ 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({ From e5ad82d7a6275165c08c2e6513779426d7dbf5f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Sat, 19 Oct 2024 15:39:13 +0200 Subject: [PATCH 25/48] feat(ci): add stripe integration context to test job --- .circleci/config.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 1f9264409..b379bf610 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 From d7c35c904bdd8e51f4ad0cfbb28f5f2e759c6ed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Sat, 19 Oct 2024 15:59:49 +0200 Subject: [PATCH 26/48] feat(billingPage): conditionally render the checkout CTAs --- .../frontend-2/components/settings/workspaces/Billing.vue | 4 +++- packages/frontend-2/composables/globals.ts | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/frontend-2/components/settings/workspaces/Billing.vue b/packages/frontend-2/components/settings/workspaces/Billing.vue index bf138d79d..21dde39be 100644 --- a/packages/frontend-2/components/settings/workspaces/Billing.vue +++ b/packages/frontend-2/components/settings/workspaces/Billing.vue @@ -58,7 +58,7 @@ -
+
@@ -84,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 { @@ -105,6 +106,7 @@ const props = defineProps<{ workspaceId: string }>() +const isBillingIntegrationEnabled = useIsBillingIntegrationEnabled() const isYearlyPlan = ref(false) const checkoutUrl = (plan: string) => diff --git a/packages/frontend-2/composables/globals.ts b/packages/frontend-2/composables/globals.ts index 344d26d31..5d608cdf4 100644 --- a/packages/frontend-2/composables/globals.ts +++ b/packages/frontend-2/composables/globals.ts @@ -33,4 +33,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 } From ecfb7a1f69191275b70e76c2724c214d58fbf4b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Sat, 19 Oct 2024 16:01:50 +0200 Subject: [PATCH 27/48] fix(gatekeeper): remove flaky test condition --- .../gatekeeper/tests/intergration/billingRepositories.spec.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/server/modules/gatekeeper/tests/intergration/billingRepositories.spec.ts b/packages/server/modules/gatekeeper/tests/intergration/billingRepositories.spec.ts index 351c3f656..7d3d0c366 100644 --- a/packages/server/modules/gatekeeper/tests/intergration/billingRepositories.spec.ts +++ b/packages/server/modules/gatekeeper/tests/intergration/billingRepositories.spec.ts @@ -161,9 +161,6 @@ describe('billing repositories @gatekeeper', () => { sessionId: checkoutSession.id }) expect(storedSession?.paymentStatus).to.equal('paid') - expect( - storedSession!.updatedAt.getTime() - checkoutSession.updatedAt.getTime() > 0 - ).to.be.true }) }) describe('getWorkspaceCheckoutSessionFactory creates a function, that', () => { From 984258f7ed04289a7d82321574ee7bd9bf901225 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Sat, 19 Oct 2024 16:08:30 +0200 Subject: [PATCH 28/48] feat(helm): add billing integration feature flag --- utils/helm/speckle-server/templates/_helpers.tpl | 3 +++ .../helm/speckle-server/templates/frontend_2/deployment.yml | 2 ++ utils/helm/speckle-server/values.schema.json | 5 +++++ utils/helm/speckle-server/values.yaml | 2 ++ 4 files changed, 12 insertions(+) diff --git a/utils/helm/speckle-server/templates/_helpers.tpl b/utils/helm/speckle-server/templates/_helpers.tpl index 0c6516cf9..628fa6c86 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 68db0c528..54b25c378 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 a8f090929..a572fe210 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 438abc12c..84523b95d 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 From 709c472a54c448de7c79538f9a32c24373537491 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Mon, 21 Oct 2024 10:33:26 +0200 Subject: [PATCH 29/48] WIP billing gql api --- .../lib/common/generated/gql/graphql.ts | 61 ++++++++ .../gatekeeper/typedefs/gatekeeper.graphql | 41 ++++++ packages/server/codegen.yml | 1 + .../modules/core/graph/generated/graphql.ts | 73 +++++++++ .../graph/generated/graphql.ts | 44 ++++++ .../gatekeeper/graph/resolvers/index.ts | 57 +++++++- .../modules/gatekeeper/helpers/graphTypes.ts | 3 + packages/server/modules/gatekeeper/index.ts | 9 ++ .../server/modules/gatekeeper/rest/billing.ts | 138 ++++++++---------- packages/server/modules/gatekeeper/scopes.ts | 10 ++ packages/server/modules/gatekeeper/stripe.ts | 40 +++++ .../server/test/graphql/generated/graphql.ts | 44 ++++++ packages/shared/package.json | 2 +- packages/shared/src/core/constants.ts | 5 + 14 files changed, 447 insertions(+), 81 deletions(-) create mode 100644 packages/server/modules/gatekeeper/helpers/graphTypes.ts create mode 100644 packages/server/modules/gatekeeper/scopes.ts create mode 100644 packages/server/modules/gatekeeper/stripe.ts diff --git a/packages/frontend-2/lib/common/generated/gql/graphql.ts b/packages/frontend-2/lib/common/generated/gql/graphql.ts index c916cc039..9cc28d4aa 100644 --- a/packages/frontend-2/lib/common/generated/gql/graphql.ts +++ b/packages/frontend-2/lib/common/generated/gql/graphql.ts @@ -428,6 +428,11 @@ export type BasicGitRepositoryMetadata = { url: Scalars['String']['output']; }; +export enum BillingInterval { + Monthly = 'monthly', + Yearly = 'yearly' +} + export type BlobMetadata = { __typename?: 'BlobMetadata'; createdAt: Scalars['DateTime']['output']; @@ -505,6 +510,23 @@ export type BranchUpdateInput = { streamId: Scalars['String']['input']; }; +export type CheckoutSession = { + __typename?: 'CheckoutSession'; + billingInterval: BillingInterval; + createdAt: Scalars['DateTime']['output']; + id: Scalars['ID']['output']; + paymentStatus: SessionPaymentStatus; + updatedAt: Scalars['DateTime']['output']; + url: Scalars['String']['output']; + workspacePlan: PaidWorkspacePlans; +}; + +export type CheckoutSessionInput = { + billingInterval: BillingInterval; + workspaceId: Scalars['ID']['input']; + workspacePlan: PaidWorkspacePlans; +}; + export type Comment = { __typename?: 'Comment'; archived: Scalars['Boolean']['output']; @@ -1701,6 +1723,12 @@ export type ObjectCreateInput = { streamId: Scalars['String']['input']; }; +export enum PaidWorkspacePlans { + Business = 'business', + Pro = 'pro', + Team = 'team' +} + export type PasswordStrengthCheckFeedback = { __typename?: 'PasswordStrengthCheckFeedback'; suggestions: Array; @@ -2844,6 +2872,11 @@ export type ServerWorkspacesInfo = { workspacesEnabled: Scalars['Boolean']['output']; }; +export enum SessionPaymentStatus { + Paid = 'paid', + Unpaid = 'unpaid' +} + export type SetPrimaryUserEmailInput = { id: Scalars['ID']['input']; }; @@ -3931,6 +3964,16 @@ export type WorkspaceBilling = { versionsCount: WorkspaceVersionsCount; }; +export type WorkspaceBillingMutations = { + __typename?: 'WorkspaceBillingMutations'; + createCheckoutSession: CheckoutSession; +}; + + +export type WorkspaceBillingMutationsCreateCheckoutSessionArgs = { + input: CheckoutSessionInput; +}; + /** Overridden by `WorkspaceCollaboratorGraphQLReturn` */ export type WorkspaceCollaborator = { __typename?: 'WorkspaceCollaborator'; @@ -4072,6 +4115,7 @@ export type WorkspaceInviteUseInput = { export type WorkspaceMutations = { __typename?: 'WorkspaceMutations'; addDomain: Workspace; + billing: WorkspaceBillingMutations; create: Workspace; delete: Scalars['Boolean']['output']; deleteDomain: Workspace; @@ -6134,6 +6178,7 @@ export type AllObjectTypes = { BlobMetadataCollection: BlobMetadataCollection, Branch: Branch, BranchCollection: BranchCollection, + CheckoutSession: CheckoutSession, Comment: Comment, CommentActivityMessage: CommentActivityMessage, CommentCollection: CommentCollection, @@ -6228,6 +6273,7 @@ export type AllObjectTypes = { WebhookEventCollection: WebhookEventCollection, Workspace: Workspace, WorkspaceBilling: WorkspaceBilling, + WorkspaceBillingMutations: WorkspaceBillingMutations, WorkspaceCollaborator: WorkspaceCollaborator, WorkspaceCollaboratorCollection: WorkspaceCollaboratorCollection, WorkspaceCollection: WorkspaceCollection, @@ -6455,6 +6501,15 @@ export type BranchCollectionFieldArgs = { items: {}, totalCount: {}, } +export type CheckoutSessionFieldArgs = { + billingInterval: {}, + createdAt: {}, + id: {}, + paymentStatus: {}, + updatedAt: {}, + url: {}, + workspacePlan: {}, +} export type CommentFieldArgs = { archived: {}, author: {}, @@ -7311,6 +7366,9 @@ export type WorkspaceBillingFieldArgs = { cost: {}, versionsCount: {}, } +export type WorkspaceBillingMutationsFieldArgs = { + createCheckoutSession: WorkspaceBillingMutationsCreateCheckoutSessionArgs, +} export type WorkspaceCollaboratorFieldArgs = { id: {}, projectRoles: {}, @@ -7357,6 +7415,7 @@ export type WorkspaceInviteMutationsFieldArgs = { } export type WorkspaceMutationsFieldArgs = { addDomain: WorkspaceMutationsAddDomainArgs, + billing: {}, create: WorkspaceMutationsCreateArgs, delete: WorkspaceMutationsDeleteArgs, deleteDomain: WorkspaceMutationsDeleteDomainArgs, @@ -7407,6 +7466,7 @@ export type AllObjectFieldArgTypes = { BlobMetadataCollection: BlobMetadataCollectionFieldArgs, Branch: BranchFieldArgs, BranchCollection: BranchCollectionFieldArgs, + CheckoutSession: CheckoutSessionFieldArgs, Comment: CommentFieldArgs, CommentActivityMessage: CommentActivityMessageFieldArgs, CommentCollection: CommentCollectionFieldArgs, @@ -7501,6 +7561,7 @@ export type AllObjectFieldArgTypes = { WebhookEventCollection: WebhookEventCollectionFieldArgs, Workspace: WorkspaceFieldArgs, WorkspaceBilling: WorkspaceBillingFieldArgs, + WorkspaceBillingMutations: WorkspaceBillingMutationsFieldArgs, WorkspaceCollaborator: WorkspaceCollaboratorFieldArgs, WorkspaceCollaboratorCollection: WorkspaceCollaboratorCollectionFieldArgs, WorkspaceCollection: WorkspaceCollectionFieldArgs, diff --git a/packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql b/packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql index 226d42939..c49991213 100644 --- a/packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql +++ b/packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql @@ -1,3 +1,44 @@ extend type Query { workspacePricingPlans: JSONObject! } + +extend type WorkspaceMutations { + billing: WorkspaceBillingMutations! +} + +enum PaidWorkspacePlans { + team + pro + business +} + +enum BillingInterval { + monthly + yearly +} + +enum SessionPaymentStatus { + paid + unpaid +} + +input CheckoutSessionInput { + workspaceId: ID! + workspacePlan: PaidWorkspacePlans! + billingInterval: BillingInterval! +} + +type CheckoutSession { + id: ID! + url: String! + workspacePlan: PaidWorkspacePlans! + paymentStatus: SessionPaymentStatus! + billingInterval: BillingInterval! + createdAt: DateTime! + updatedAt: DateTime! +} + +type WorkspaceBillingMutations { + createCheckoutSession(input: CheckoutSessionInput!): CheckoutSession! + @hasScope(scope: "workspace:billing") +} diff --git a/packages/server/codegen.yml b/packages/server/codegen.yml index 1b2a77399..353f09ec2 100644 --- a/packages/server/codegen.yml +++ b/packages/server/codegen.yml @@ -61,6 +61,7 @@ generates: WorkspaceMutations: '@/modules/workspacesCore/helpers/graphTypes#WorkspaceMutationsGraphQLReturn' WorkspaceInviteMutations: '@/modules/workspacesCore/helpers/graphTypes#WorkspaceInviteMutationsGraphQLReturn' WorkspaceProjectMutations: '@/modules/workspacesCore/helpers/graphTypes#WorkspaceProjectMutationsGraphQLReturn' + WorkspaceBillingMutations: '@/modules/gatekeeper/helpers/graphTypes#WorkspaceBillingMutationsGraphQLReturn' PendingWorkspaceCollaborator: '@/modules/workspacesCore/helpers/graphTypes#PendingWorkspaceCollaboratorGraphQLReturn' WorkspaceCollaborator: '@/modules/workspacesCore/helpers/graphTypes#WorkspaceCollaboratorGraphQLReturn' Webhook: '@/modules/webhooks/helpers/graphTypes#WebhookGraphQLReturn' diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index 526801248..d9c58814c 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -6,6 +6,7 @@ import { PendingStreamCollaboratorGraphQLReturn } from '@/modules/serverinvites/ import { FileUploadGraphQLReturn } from '@/modules/fileuploads/helpers/types'; import { AutomateFunctionGraphQLReturn, AutomateFunctionReleaseGraphQLReturn, AutomationGraphQLReturn, AutomationRevisionGraphQLReturn, AutomationRevisionFunctionGraphQLReturn, AutomateRunGraphQLReturn, AutomationRunTriggerGraphQLReturn, AutomationRevisionTriggerDefinitionGraphQLReturn, AutomateFunctionRunGraphQLReturn, TriggeredAutomationsStatusGraphQLReturn, ProjectAutomationMutationsGraphQLReturn, ProjectTriggeredAutomationsStatusUpdatedMessageGraphQLReturn, ProjectAutomationsUpdatedMessageGraphQLReturn, UserAutomateInfoGraphQLReturn } from '@/modules/automate/helpers/graphTypes'; import { WorkspaceGraphQLReturn, WorkspaceBillingGraphQLReturn, WorkspaceMutationsGraphQLReturn, WorkspaceInviteMutationsGraphQLReturn, WorkspaceProjectMutationsGraphQLReturn, PendingWorkspaceCollaboratorGraphQLReturn, WorkspaceCollaboratorGraphQLReturn, ProjectRoleGraphQLReturn } from '@/modules/workspacesCore/helpers/graphTypes'; +import { WorkspaceBillingMutationsGraphQLReturn } from '@/modules/gatekeeper/helpers/graphTypes'; import { WebhookGraphQLReturn } from '@/modules/webhooks/helpers/graphTypes'; import { SmartTextEditorValueSchema } from '@/modules/core/services/richTextEditorService'; import { BlobStorageItem } from '@/modules/blobstorage/domain/types'; @@ -442,6 +443,11 @@ export type BasicGitRepositoryMetadata = { url: Scalars['String']['output']; }; +export enum BillingInterval { + Monthly = 'monthly', + Yearly = 'yearly' +} + export type BlobMetadata = { __typename?: 'BlobMetadata'; createdAt: Scalars['DateTime']['output']; @@ -519,6 +525,23 @@ export type BranchUpdateInput = { streamId: Scalars['String']['input']; }; +export type CheckoutSession = { + __typename?: 'CheckoutSession'; + billingInterval: BillingInterval; + createdAt: Scalars['DateTime']['output']; + id: Scalars['ID']['output']; + paymentStatus: SessionPaymentStatus; + updatedAt: Scalars['DateTime']['output']; + url: Scalars['String']['output']; + workspacePlan: PaidWorkspacePlans; +}; + +export type CheckoutSessionInput = { + billingInterval: BillingInterval; + workspaceId: Scalars['ID']['input']; + workspacePlan: PaidWorkspacePlans; +}; + export type Comment = { __typename?: 'Comment'; archived: Scalars['Boolean']['output']; @@ -1720,6 +1743,12 @@ export type ObjectCreateInput = { streamId: Scalars['String']['input']; }; +export enum PaidWorkspacePlans { + Business = 'business', + Pro = 'pro', + Team = 'team' +} + export type PasswordStrengthCheckFeedback = { __typename?: 'PasswordStrengthCheckFeedback'; suggestions: Array; @@ -2863,6 +2892,11 @@ export type ServerWorkspacesInfo = { workspacesEnabled: Scalars['Boolean']['output']; }; +export enum SessionPaymentStatus { + Paid = 'paid', + Unpaid = 'unpaid' +} + export type SetPrimaryUserEmailInput = { id: Scalars['ID']['input']; }; @@ -3950,6 +3984,16 @@ export type WorkspaceBilling = { versionsCount: WorkspaceVersionsCount; }; +export type WorkspaceBillingMutations = { + __typename?: 'WorkspaceBillingMutations'; + createCheckoutSession: CheckoutSession; +}; + + +export type WorkspaceBillingMutationsCreateCheckoutSessionArgs = { + input: CheckoutSessionInput; +}; + /** Overridden by `WorkspaceCollaboratorGraphQLReturn` */ export type WorkspaceCollaborator = { __typename?: 'WorkspaceCollaborator'; @@ -4091,6 +4135,7 @@ export type WorkspaceInviteUseInput = { export type WorkspaceMutations = { __typename?: 'WorkspaceMutations'; addDomain: Workspace; + billing: WorkspaceBillingMutations; create: Workspace; delete: Scalars['Boolean']['output']; deleteDomain: Workspace; @@ -4337,6 +4382,7 @@ export type ResolversTypes = { AvatarUser: ResolverTypeWrapper; BasicGitRepositoryMetadata: ResolverTypeWrapper; BigInt: ResolverTypeWrapper; + BillingInterval: BillingInterval; BlobMetadata: ResolverTypeWrapper; BlobMetadataCollection: ResolverTypeWrapper & { items?: Maybe> }>; Boolean: ResolverTypeWrapper; @@ -4345,6 +4391,8 @@ export type ResolversTypes = { BranchCreateInput: BranchCreateInput; BranchDeleteInput: BranchDeleteInput; BranchUpdateInput: BranchUpdateInput; + CheckoutSession: ResolverTypeWrapper; + CheckoutSessionInput: CheckoutSessionInput; Comment: ResolverTypeWrapper; CommentActivityMessage: ResolverTypeWrapper & { comment: ResolversTypes['Comment'] }>; CommentCollection: ResolverTypeWrapper & { items: Array }>; @@ -4404,6 +4452,7 @@ export type ResolversTypes = { Object: ResolverTypeWrapper; ObjectCollection: ResolverTypeWrapper & { objects: Array }>; ObjectCreateInput: ObjectCreateInput; + PaidWorkspacePlans: PaidWorkspacePlans; PasswordStrengthCheckFeedback: ResolverTypeWrapper; PasswordStrengthCheckResults: ResolverTypeWrapper; PendingStreamCollaborator: ResolverTypeWrapper; @@ -4472,6 +4521,7 @@ export type ResolversTypes = { ServerStatistics: ResolverTypeWrapper; ServerStats: ResolverTypeWrapper; ServerWorkspacesInfo: ResolverTypeWrapper; + SessionPaymentStatus: SessionPaymentStatus; SetPrimaryUserEmailInput: SetPrimaryUserEmailInput; SmartTextEditorValue: ResolverTypeWrapper; SortDirection: SortDirection; @@ -4529,6 +4579,7 @@ export type ResolversTypes = { WebhookUpdateInput: WebhookUpdateInput; Workspace: ResolverTypeWrapper; WorkspaceBilling: ResolverTypeWrapper; + WorkspaceBillingMutations: ResolverTypeWrapper; WorkspaceCollaborator: ResolverTypeWrapper; WorkspaceCollaboratorCollection: ResolverTypeWrapper & { items: Array }>; WorkspaceCollection: ResolverTypeWrapper & { items: Array }>; @@ -4605,6 +4656,8 @@ export type ResolversParentTypes = { BranchCreateInput: BranchCreateInput; BranchDeleteInput: BranchDeleteInput; BranchUpdateInput: BranchUpdateInput; + CheckoutSession: CheckoutSession; + CheckoutSessionInput: CheckoutSessionInput; Comment: CommentGraphQLReturn; CommentActivityMessage: Omit & { comment: ResolversParentTypes['Comment'] }; CommentCollection: Omit & { items: Array }; @@ -4770,6 +4823,7 @@ export type ResolversParentTypes = { WebhookUpdateInput: WebhookUpdateInput; Workspace: WorkspaceGraphQLReturn; WorkspaceBilling: WorkspaceBillingGraphQLReturn; + WorkspaceBillingMutations: WorkspaceBillingMutationsGraphQLReturn; WorkspaceCollaborator: WorkspaceCollaboratorGraphQLReturn; WorkspaceCollaboratorCollection: Omit & { items: Array }; WorkspaceCollection: Omit & { items: Array }; @@ -5118,6 +5172,17 @@ export type BranchCollectionResolvers; }; +export type CheckoutSessionResolvers = { + billingInterval?: Resolver; + createdAt?: Resolver; + id?: Resolver; + paymentStatus?: Resolver; + updatedAt?: Resolver; + url?: Resolver; + workspacePlan?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type CommentResolvers = { archived?: Resolver; author?: Resolver; @@ -6167,6 +6232,11 @@ export type WorkspaceBillingResolvers; }; +export type WorkspaceBillingMutationsResolvers = { + createCheckoutSession?: Resolver>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type WorkspaceCollaboratorResolvers = { id?: Resolver; projectRoles?: Resolver, ParentType, ContextType>; @@ -6229,6 +6299,7 @@ export type WorkspaceInviteMutationsResolvers = { addDomain?: Resolver>; + billing?: Resolver; create?: Resolver>; delete?: Resolver>; deleteDomain?: Resolver>; @@ -6288,6 +6359,7 @@ export type Resolvers = { BlobMetadataCollection?: BlobMetadataCollectionResolvers; Branch?: BranchResolvers; BranchCollection?: BranchCollectionResolvers; + CheckoutSession?: CheckoutSessionResolvers; Comment?: CommentResolvers; CommentActivityMessage?: CommentActivityMessageResolvers; CommentCollection?: CommentCollectionResolvers; @@ -6384,6 +6456,7 @@ export type Resolvers = { WebhookEventCollection?: WebhookEventCollectionResolvers; Workspace?: WorkspaceResolvers; WorkspaceBilling?: WorkspaceBillingResolvers; + WorkspaceBillingMutations?: WorkspaceBillingMutationsResolvers; WorkspaceCollaborator?: WorkspaceCollaboratorResolvers; WorkspaceCollaboratorCollection?: WorkspaceCollaboratorCollectionResolvers; WorkspaceCollection?: WorkspaceCollectionResolvers; 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 45dfde8ef..81fb0575d 100644 --- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts +++ b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts @@ -426,6 +426,11 @@ export type BasicGitRepositoryMetadata = { url: Scalars['String']['output']; }; +export enum BillingInterval { + Monthly = 'monthly', + Yearly = 'yearly' +} + export type BlobMetadata = { __typename?: 'BlobMetadata'; createdAt: Scalars['DateTime']['output']; @@ -503,6 +508,23 @@ export type BranchUpdateInput = { streamId: Scalars['String']['input']; }; +export type CheckoutSession = { + __typename?: 'CheckoutSession'; + billingInterval: BillingInterval; + createdAt: Scalars['DateTime']['output']; + id: Scalars['ID']['output']; + paymentStatus: SessionPaymentStatus; + updatedAt: Scalars['DateTime']['output']; + url: Scalars['String']['output']; + workspacePlan: PaidWorkspacePlans; +}; + +export type CheckoutSessionInput = { + billingInterval: BillingInterval; + workspaceId: Scalars['ID']['input']; + workspacePlan: PaidWorkspacePlans; +}; + export type Comment = { __typename?: 'Comment'; archived: Scalars['Boolean']['output']; @@ -1704,6 +1726,12 @@ export type ObjectCreateInput = { streamId: Scalars['String']['input']; }; +export enum PaidWorkspacePlans { + Business = 'business', + Pro = 'pro', + Team = 'team' +} + export type PasswordStrengthCheckFeedback = { __typename?: 'PasswordStrengthCheckFeedback'; suggestions: Array; @@ -2847,6 +2875,11 @@ export type ServerWorkspacesInfo = { workspacesEnabled: Scalars['Boolean']['output']; }; +export enum SessionPaymentStatus { + Paid = 'paid', + Unpaid = 'unpaid' +} + export type SetPrimaryUserEmailInput = { id: Scalars['ID']['input']; }; @@ -3934,6 +3967,16 @@ export type WorkspaceBilling = { versionsCount: WorkspaceVersionsCount; }; +export type WorkspaceBillingMutations = { + __typename?: 'WorkspaceBillingMutations'; + createCheckoutSession: CheckoutSession; +}; + + +export type WorkspaceBillingMutationsCreateCheckoutSessionArgs = { + input: CheckoutSessionInput; +}; + /** Overridden by `WorkspaceCollaboratorGraphQLReturn` */ export type WorkspaceCollaborator = { __typename?: 'WorkspaceCollaborator'; @@ -4075,6 +4118,7 @@ export type WorkspaceInviteUseInput = { export type WorkspaceMutations = { __typename?: 'WorkspaceMutations'; addDomain: Workspace; + billing: WorkspaceBillingMutations; create: Workspace; delete: Scalars['Boolean']['output']; deleteDomain: Workspace; diff --git a/packages/server/modules/gatekeeper/graph/resolvers/index.ts b/packages/server/modules/gatekeeper/graph/resolvers/index.ts index 670558403..275089e9a 100644 --- a/packages/server/modules/gatekeeper/graph/resolvers/index.ts +++ b/packages/server/modules/gatekeeper/graph/resolvers/index.ts @@ -1,6 +1,22 @@ -import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' +import { getFeatureFlags, getFrontendOrigin } from '@/modules/shared/helpers/envHelper' import { Resolvers } from '@/modules/core/graph/generated/graphql' import { pricingTable } from '@/modules/gatekeeper/domain/workspacePricing' +import { authorizeResolver } from '@/modules/shared' +import { Roles } from '@speckle/shared' +import { + countWorkspaceRoleWithOptionalProjectRoleFactory, + getWorkspaceFactory +} from '@/modules/workspaces/repositories/workspaces' +import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace' +import { db } from '@/db/knex' +import { createCheckoutSessionFactory } from '@/modules/gatekeeper/clients/stripe' +import { getWorkspacePlanPrice, stripe } from '@/modules/gatekeeper/stripe' +import { startCheckoutSessionFactory } from '@/modules/gatekeeper/services/checkout' +import { + getWorkspaceCheckoutSessionFactory, + getWorkspacePlanFactory, + saveCheckoutSessionFactory +} from '@/modules/gatekeeper/repositories/billing' const { FF_GATEKEEPER_MODULE_ENABLED } = getFeatureFlags() @@ -10,6 +26,45 @@ export = FF_GATEKEEPER_MODULE_ENABLED workspacePricingPlans: async () => { return pricingTable } + }, + WorkspaceMutations: () => ({}), + WorkspaceBillingMutations: { + createCheckoutSession: async (parent, args, ctx) => { + const { workspaceId, workspacePlan, billingInterval } = args.input + const workspace = await getWorkspaceFactory({ db })({ workspaceId }) + + if (!workspace) throw new WorkspaceNotFoundError() + + await authorizeResolver( + ctx.userId, + workspaceId, + Roles.Workspace.Admin, + ctx.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 + }) + + return session + } } } as Resolvers) : {} diff --git a/packages/server/modules/gatekeeper/helpers/graphTypes.ts b/packages/server/modules/gatekeeper/helpers/graphTypes.ts new file mode 100644 index 000000000..90b501e55 --- /dev/null +++ b/packages/server/modules/gatekeeper/helpers/graphTypes.ts @@ -0,0 +1,3 @@ +import { MutationsObjectGraphQLReturn } from '@/modules/core/helpers/graphTypes' + +export type WorkspaceBillingMutationsGraphQLReturn = MutationsObjectGraphQLReturn diff --git a/packages/server/modules/gatekeeper/index.ts b/packages/server/modules/gatekeeper/index.ts index 5e37d9315..083fe6372 100644 --- a/packages/server/modules/gatekeeper/index.ts +++ b/packages/server/modules/gatekeeper/index.ts @@ -3,10 +3,18 @@ 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' +import { registerOrUpdateScopeFactory } from '@/modules/shared/repositories/scopes' +import { db } from '@/db/knex' +import { gatekeeperScopes } from '@/modules/gatekeeper/scopes' const { FF_GATEKEEPER_MODULE_ENABLED, FF_BILLING_INTEGRATION_ENABLED } = getFeatureFlags() +const initScopes = async () => { + const registerFunc = registerOrUpdateScopeFactory({ db }) + await Promise.all(gatekeeperScopes.map((scope) => registerFunc({ scope }))) +} + const gatekeeperModule: SpeckleModule = { async init(app, isInitial) { if (!FF_GATEKEEPER_MODULE_ENABLED) return @@ -24,6 +32,7 @@ const gatekeeperModule: SpeckleModule = { 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) { + await initScopes() app.use(billingRouter) const isLicenseValid = await validateModuleLicense({ diff --git a/packages/server/modules/gatekeeper/rest/billing.ts b/packages/server/modules/gatekeeper/rest/billing.ts index da2a815f9..a4193275d 100644 --- a/packages/server/modules/gatekeeper/rest/billing.ts +++ b/packages/server/modules/gatekeeper/rest/billing.ts @@ -1,19 +1,15 @@ 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 { authorizeResolver, validateScopes } from '@/modules/shared' +import { ensureError, Roles, Scopes } 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 { @@ -40,47 +36,14 @@ import { 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' +import { stripe, getWorkspacePlanPrice } from '@/modules/gatekeeper/stripe' 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', @@ -97,6 +60,7 @@ router.get( if (!workspace) throw new WorkspaceNotFoundError() + await validateScopes(req.context.scopes, Scopes.Gatekeeper.WorkspaceBilling) await authorizeResolver( req.context.userId, workspaceId, @@ -148,7 +112,10 @@ router.post('/api/v1/billing/webhooks', async (req, res) => { switch (event.type) { case 'checkout.session.async_payment_failed': - // TODO: need to alert the user and delete the session ? + // if payment fails, we delete the failed session + await deleteCheckoutSessionFactory({ db })({ + checkoutSessionId: event.data.object.id + }) break case 'checkout.session.async_payment_succeeded': case 'checkout.session.completed': @@ -157,50 +124,63 @@ router.post('/api/v1/billing/webhooks', async (req, res) => { 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 + switch (session.payment_status) { + case 'no_payment_required': + // we do not need to support this status + break + case '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 + const subscriptionId = + typeof session.subscription === 'string' + ? session.subscription + : session.subscription.id - // this must use a transaction + // this must use a transaction - const trx = await db.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 + const completeCheckout = completeCheckoutSessionFactory({ + getCheckoutSession: getCheckoutSessionFactory({ db: trx }), + updateCheckoutSessionStatus: updateCheckoutSessionStatusFactory({ + db: trx }), - trx - ) - } catch (err) { - if (err instanceof WorkspaceAlreadyPaidError) { - // ignore the request, this is prob a replay from stripe - } else { - throw err + 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 'unpaid': + // if payment fails, we delete the failed session + await deleteCheckoutSessionFactory({ db })({ + checkoutSessionId: event.data.object.id + }) } break diff --git a/packages/server/modules/gatekeeper/scopes.ts b/packages/server/modules/gatekeeper/scopes.ts new file mode 100644 index 000000000..047faec89 --- /dev/null +++ b/packages/server/modules/gatekeeper/scopes.ts @@ -0,0 +1,10 @@ +import { TokenScopeData } from '@/modules/shared/domain/rolesAndScopes/types' +import { Scopes } from '@speckle/shared' + +export const gatekeeperScopes: TokenScopeData[] = [ + { + name: Scopes.Gatekeeper.WorkspaceBilling, + description: 'Scope for managing workspace billing', + public: false + } +] diff --git a/packages/server/modules/gatekeeper/stripe.ts b/packages/server/modules/gatekeeper/stripe.ts new file mode 100644 index 000000000..b5c13f05a --- /dev/null +++ b/packages/server/modules/gatekeeper/stripe.ts @@ -0,0 +1,40 @@ +import { GetWorkspacePlanPrice } from '@/modules/gatekeeper/domain/billing' +import { + WorkspacePlanBillingIntervals, + WorkspacePricingPlans +} from '@/modules/gatekeeper/domain/workspacePricing' +import { getStringFromEnv, getStripeApiKey } from '@/modules/shared/helpers/envHelper' +import { Stripe } from 'stripe' + +export const stripe = new Stripe(getStripeApiKey(), { typescript: true }) + +export 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') + } +}) + +export const getWorkspacePlanPrice: GetWorkspacePlanPrice = ({ + workspacePlan, + billingInterval +}) => workspacePlanPrices()[workspacePlan][billingInterval] diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index 9caf57386..73a970269 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -427,6 +427,11 @@ export type BasicGitRepositoryMetadata = { url: Scalars['String']['output']; }; +export enum BillingInterval { + Monthly = 'monthly', + Yearly = 'yearly' +} + export type BlobMetadata = { __typename?: 'BlobMetadata'; createdAt: Scalars['DateTime']['output']; @@ -504,6 +509,23 @@ export type BranchUpdateInput = { streamId: Scalars['String']['input']; }; +export type CheckoutSession = { + __typename?: 'CheckoutSession'; + billingInterval: BillingInterval; + createdAt: Scalars['DateTime']['output']; + id: Scalars['ID']['output']; + paymentStatus: SessionPaymentStatus; + updatedAt: Scalars['DateTime']['output']; + url: Scalars['String']['output']; + workspacePlan: PaidWorkspacePlans; +}; + +export type CheckoutSessionInput = { + billingInterval: BillingInterval; + workspaceId: Scalars['ID']['input']; + workspacePlan: PaidWorkspacePlans; +}; + export type Comment = { __typename?: 'Comment'; archived: Scalars['Boolean']['output']; @@ -1705,6 +1727,12 @@ export type ObjectCreateInput = { streamId: Scalars['String']['input']; }; +export enum PaidWorkspacePlans { + Business = 'business', + Pro = 'pro', + Team = 'team' +} + export type PasswordStrengthCheckFeedback = { __typename?: 'PasswordStrengthCheckFeedback'; suggestions: Array; @@ -2848,6 +2876,11 @@ export type ServerWorkspacesInfo = { workspacesEnabled: Scalars['Boolean']['output']; }; +export enum SessionPaymentStatus { + Paid = 'paid', + Unpaid = 'unpaid' +} + export type SetPrimaryUserEmailInput = { id: Scalars['ID']['input']; }; @@ -3935,6 +3968,16 @@ export type WorkspaceBilling = { versionsCount: WorkspaceVersionsCount; }; +export type WorkspaceBillingMutations = { + __typename?: 'WorkspaceBillingMutations'; + createCheckoutSession: CheckoutSession; +}; + + +export type WorkspaceBillingMutationsCreateCheckoutSessionArgs = { + input: CheckoutSessionInput; +}; + /** Overridden by `WorkspaceCollaboratorGraphQLReturn` */ export type WorkspaceCollaborator = { __typename?: 'WorkspaceCollaborator'; @@ -4076,6 +4119,7 @@ export type WorkspaceInviteUseInput = { export type WorkspaceMutations = { __typename?: 'WorkspaceMutations'; addDomain: Workspace; + billing: WorkspaceBillingMutations; create: Workspace; delete: Scalars['Boolean']['output']; deleteDomain: Workspace; diff --git a/packages/shared/package.json b/packages/shared/package.json index 4d92c4540..3191ee7a0 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -12,7 +12,7 @@ "main": "./dist/commonjs/index.js", "types": "./dist/commonjs/index.d.ts", "scripts": { - "build": "echo \"Building shared...\" && NODE_ENV=production tshy && echo \"Done building shared!\"", + "build": "NODE_ENV=production tshy", "dev": "tshy --watch", "prepack": "yarn build", "lint:eslint": "eslint .", diff --git a/packages/shared/src/core/constants.ts b/packages/shared/src/core/constants.ts index e259acdf5..1365b4fc1 100644 --- a/packages/shared/src/core/constants.ts +++ b/packages/shared/src/core/constants.ts @@ -123,6 +123,9 @@ export const Scopes = Object.freeze({ Read: 'workspace:read', Update: 'workspace:update', Delete: 'workspace:delete' + }, + Gatekeeper: { + WorkspaceBilling: 'workspace:billing' } }) @@ -138,6 +141,8 @@ export type AutomateFunctionScopes = (typeof Scopes)['AutomateFunctions'][keyof (typeof Scopes)['AutomateFunctions']] export type WorkspaceScopes = (typeof Scopes)['Workspaces'][keyof (typeof Scopes)['Workspaces']] +export type GatekeeperScopes = + (typeof Scopes)['Gatekeeper'][keyof (typeof Scopes)['Gatekeeper']] export type AvailableScopes = | StreamScopes From e9cdb7e970b63b4840638682a4b9842d959688d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Mon, 21 Oct 2024 18:13:58 +0200 Subject: [PATCH 30/48] feat(gatekeeper): cancel checkout session api --- .../assets/gatekeeper/typedefs/gatekeeper.graphql | 9 +++++++-- .../server/modules/core/graph/generated/graphql.ts | 14 ++++++++++++++ .../cross-server-sync/graph/generated/graphql.ts | 11 +++++++++++ .../server/modules/gatekeeper/clients/stripe.ts | 1 - .../modules/gatekeeper/graph/resolvers/index.ts | 13 +++++++++++++ packages/server/test/graphql/generated/graphql.ts | 11 +++++++++++ 6 files changed, 56 insertions(+), 3 deletions(-) diff --git a/packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql b/packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql index c49991213..3e15473c2 100644 --- a/packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql +++ b/packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql @@ -3,7 +3,7 @@ extend type Query { } extend type WorkspaceMutations { - billing: WorkspaceBillingMutations! + billing: WorkspaceBillingMutations! @hasScope(scope: "workspace:billing") } enum PaidWorkspacePlans { @@ -38,7 +38,12 @@ type CheckoutSession { updatedAt: DateTime! } +input CancelCheckoutSessionInput { + sessionId: ID! + workspaceId: ID! +} + type WorkspaceBillingMutations { createCheckoutSession(input: CheckoutSessionInput!): CheckoutSession! - @hasScope(scope: "workspace:billing") + cancelCheckoutSession(input: CancelCheckoutSessionInput!): Boolean! } diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index d9c58814c..9382f1212 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -525,6 +525,11 @@ export type BranchUpdateInput = { streamId: Scalars['String']['input']; }; +export type CancelCheckoutSessionInput = { + sessionId: Scalars['ID']['input']; + workspaceId: Scalars['ID']['input']; +}; + export type CheckoutSession = { __typename?: 'CheckoutSession'; billingInterval: BillingInterval; @@ -3986,10 +3991,16 @@ export type WorkspaceBilling = { export type WorkspaceBillingMutations = { __typename?: 'WorkspaceBillingMutations'; + cancelCheckoutSession: Scalars['Boolean']['output']; createCheckoutSession: CheckoutSession; }; +export type WorkspaceBillingMutationsCancelCheckoutSessionArgs = { + input: CancelCheckoutSessionInput; +}; + + export type WorkspaceBillingMutationsCreateCheckoutSessionArgs = { input: CheckoutSessionInput; }; @@ -4391,6 +4402,7 @@ export type ResolversTypes = { BranchCreateInput: BranchCreateInput; BranchDeleteInput: BranchDeleteInput; BranchUpdateInput: BranchUpdateInput; + CancelCheckoutSessionInput: CancelCheckoutSessionInput; CheckoutSession: ResolverTypeWrapper; CheckoutSessionInput: CheckoutSessionInput; Comment: ResolverTypeWrapper; @@ -4656,6 +4668,7 @@ export type ResolversParentTypes = { BranchCreateInput: BranchCreateInput; BranchDeleteInput: BranchDeleteInput; BranchUpdateInput: BranchUpdateInput; + CancelCheckoutSessionInput: CancelCheckoutSessionInput; CheckoutSession: CheckoutSession; CheckoutSessionInput: CheckoutSessionInput; Comment: CommentGraphQLReturn; @@ -6233,6 +6246,7 @@ export type WorkspaceBillingResolvers = { + cancelCheckoutSession?: Resolver>; createCheckoutSession?: Resolver>; __isTypeOf?: IsTypeOfResolverFn; }; 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 81fb0575d..1d8a20ce5 100644 --- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts +++ b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts @@ -508,6 +508,11 @@ export type BranchUpdateInput = { streamId: Scalars['String']['input']; }; +export type CancelCheckoutSessionInput = { + sessionId: Scalars['ID']['input']; + workspaceId: Scalars['ID']['input']; +}; + export type CheckoutSession = { __typename?: 'CheckoutSession'; billingInterval: BillingInterval; @@ -3969,10 +3974,16 @@ export type WorkspaceBilling = { export type WorkspaceBillingMutations = { __typename?: 'WorkspaceBillingMutations'; + cancelCheckoutSession: Scalars['Boolean']['output']; createCheckoutSession: CheckoutSession; }; +export type WorkspaceBillingMutationsCancelCheckoutSessionArgs = { + input: CancelCheckoutSessionInput; +}; + + export type WorkspaceBillingMutationsCreateCheckoutSessionArgs = { input: CheckoutSessionInput; }; diff --git a/packages/server/modules/gatekeeper/clients/stripe.ts b/packages/server/modules/gatekeeper/clients/stripe.ts index 2bd749cfd..95d8f0bcd 100644 --- a/packages/server/modules/gatekeeper/clients/stripe.ts +++ b/packages/server/modules/gatekeeper/clients/stripe.ts @@ -57,7 +57,6 @@ export const createCheckoutSessionFactory = 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}` }) diff --git a/packages/server/modules/gatekeeper/graph/resolvers/index.ts b/packages/server/modules/gatekeeper/graph/resolvers/index.ts index 275089e9a..2b7f6fa0f 100644 --- a/packages/server/modules/gatekeeper/graph/resolvers/index.ts +++ b/packages/server/modules/gatekeeper/graph/resolvers/index.ts @@ -13,6 +13,7 @@ import { createCheckoutSessionFactory } from '@/modules/gatekeeper/clients/strip import { getWorkspacePlanPrice, stripe } from '@/modules/gatekeeper/stripe' import { startCheckoutSessionFactory } from '@/modules/gatekeeper/services/checkout' import { + deleteCheckoutSessionFactory, getWorkspaceCheckoutSessionFactory, getWorkspacePlanFactory, saveCheckoutSessionFactory @@ -29,6 +30,18 @@ export = FF_GATEKEEPER_MODULE_ENABLED }, WorkspaceMutations: () => ({}), WorkspaceBillingMutations: { + cancelCheckoutSession: async (parent, args, ctx) => { + const { workspaceId, sessionId } = args.input + + await authorizeResolver( + ctx.userId, + workspaceId, + Roles.Workspace.Admin, + ctx.resourceAccessRules + ) + await deleteCheckoutSessionFactory({ db })({ checkoutSessionId: sessionId }) + return true + }, createCheckoutSession: async (parent, args, ctx) => { const { workspaceId, workspacePlan, billingInterval } = args.input const workspace = await getWorkspaceFactory({ db })({ workspaceId }) diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index 73a970269..fe757ac50 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -509,6 +509,11 @@ export type BranchUpdateInput = { streamId: Scalars['String']['input']; }; +export type CancelCheckoutSessionInput = { + sessionId: Scalars['ID']['input']; + workspaceId: Scalars['ID']['input']; +}; + export type CheckoutSession = { __typename?: 'CheckoutSession'; billingInterval: BillingInterval; @@ -3970,10 +3975,16 @@ export type WorkspaceBilling = { export type WorkspaceBillingMutations = { __typename?: 'WorkspaceBillingMutations'; + cancelCheckoutSession: Scalars['Boolean']['output']; createCheckoutSession: CheckoutSession; }; +export type WorkspaceBillingMutationsCancelCheckoutSessionArgs = { + input: CancelCheckoutSessionInput; +}; + + export type WorkspaceBillingMutationsCreateCheckoutSessionArgs = { input: CheckoutSessionInput; }; From ee3b67a3a9b80e292b85afe529e03cbd95d95510 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Mon, 21 Oct 2024 19:18:22 +0200 Subject: [PATCH 31/48] feat(gatekeeper): handle existing checkout sessions, when trying to create a new one --- .../gatekeeper/graph/resolvers/index.ts | 3 +- .../server/modules/gatekeeper/rest/billing.ts | 3 +- .../modules/gatekeeper/services/checkout.ts | 39 ++- .../gatekeeper/tests/unit/checkout.spec.ts | 277 +++++++++++++++++- 4 files changed, 314 insertions(+), 8 deletions(-) diff --git a/packages/server/modules/gatekeeper/graph/resolvers/index.ts b/packages/server/modules/gatekeeper/graph/resolvers/index.ts index 2b7f6fa0f..db09deae9 100644 --- a/packages/server/modules/gatekeeper/graph/resolvers/index.ts +++ b/packages/server/modules/gatekeeper/graph/resolvers/index.ts @@ -68,7 +68,8 @@ export = FF_GATEKEEPER_MODULE_ENABLED getWorkspacePlan: getWorkspacePlanFactory({ db }), countRole, createCheckoutSession, - saveCheckoutSession: saveCheckoutSessionFactory({ db }) + saveCheckoutSession: saveCheckoutSessionFactory({ db }), + deleteCheckoutSession: deleteCheckoutSessionFactory({ db }) })({ workspacePlan, workspaceId, diff --git a/packages/server/modules/gatekeeper/rest/billing.ts b/packages/server/modules/gatekeeper/rest/billing.ts index 921365dbd..631d316ee 100644 --- a/packages/server/modules/gatekeeper/rest/billing.ts +++ b/packages/server/modules/gatekeeper/rest/billing.ts @@ -80,7 +80,8 @@ export const getBillingRouter = (): Router => { getWorkspacePlan: getWorkspacePlanFactory({ db }), countRole, createCheckoutSession, - saveCheckoutSession: saveCheckoutSessionFactory({ db }) + saveCheckoutSession: saveCheckoutSessionFactory({ db }), + deleteCheckoutSession: deleteCheckoutSessionFactory({ db }) })({ workspacePlan, workspaceId, workspaceSlug: workspace.slug, billingInterval }) req.res?.redirect(session.url) diff --git a/packages/server/modules/gatekeeper/services/checkout.ts b/packages/server/modules/gatekeeper/services/checkout.ts index b9a9e7611..c84f3cc4a 100644 --- a/packages/server/modules/gatekeeper/services/checkout.ts +++ b/packages/server/modules/gatekeeper/services/checkout.ts @@ -8,7 +8,8 @@ import { SaveWorkspaceSubscription, UpsertPaidWorkspacePlan, GetSubscriptionData, - GetWorkspaceCheckoutSession + GetWorkspaceCheckoutSession, + DeleteCheckoutSession } from '@/modules/gatekeeper/domain/billing' import { PaidWorkspacePlans, @@ -25,12 +26,14 @@ import { Roles, throwUncoveredError } from '@speckle/shared' export const startCheckoutSessionFactory = ({ getWorkspaceCheckoutSession, + deleteCheckoutSession, getWorkspacePlan, countRole, createCheckoutSession, saveCheckoutSession }: { getWorkspaceCheckoutSession: GetWorkspaceCheckoutSession + deleteCheckoutSession: DeleteCheckoutSession getWorkspacePlan: GetWorkspacePlan countRole: CountWorkspaceRoleWithOptionalProjectRole createCheckoutSession: CreateCheckoutSession @@ -50,6 +53,7 @@ export const startCheckoutSessionFactory = // 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 @@ -60,9 +64,38 @@ export const startCheckoutSessionFactory = case 'paymentFailed': throw new WorkspaceAlreadyPaidError() case 'cancelled': + const existingCheckoutSession = await getWorkspaceCheckoutSession({ + workspaceId + }) + if (existingCheckoutSession) + await deleteCheckoutSession({ + checkoutSessionId: existingCheckoutSession?.id + }) + break + // 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': + // if there is already a checkout session for the workspace, stop, someone else is maybe trying to pay for the workspace + const workspaceCheckoutSession = await getWorkspaceCheckoutSession({ + workspaceId + }) + if (workspaceCheckoutSession) { + if (workspaceCheckoutSession.paymentStatus === 'paid') + // this is should not be possible, but its better to be checking it here, than double charging the customer + throw new WorkspaceAlreadyPaidError() + if ( + new Date().getTime() - workspaceCheckoutSession.createdAt.getTime() > + 10 * 60 * 1000 + ) { + await deleteCheckoutSession({ + checkoutSessionId: workspaceCheckoutSession.id + }) + } else { + throw new WorkspaceCheckoutSessionInProgressError() + } + } + // lets go ahead and pay break default: @@ -70,10 +103,6 @@ export const startCheckoutSessionFactory = } } - // 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 }), diff --git a/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts b/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts index dc437de48..308cee180 100644 --- a/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts +++ b/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts @@ -43,6 +43,9 @@ describe('checkout @gatekeeper', () => { }, saveCheckoutSession: () => { expect.fail() + }, + deleteCheckoutSession: () => { + expect.fail() } })({ workspaceId, @@ -71,6 +74,9 @@ describe('checkout @gatekeeper', () => { createCheckoutSession: () => { expect.fail() }, + deleteCheckoutSession: () => { + expect.fail() + }, saveCheckoutSession: () => { expect.fail() } @@ -83,6 +89,49 @@ describe('checkout @gatekeeper', () => { ) expect(err.message).to.be.equal(new WorkspaceAlreadyPaidError().message) }) + it('does not allow checkout for a workspace, that already has a recent checkout session', async () => { + const workspaceId = cryptoRandomString({ length: 10 }) + const err = await expectToThrow(() => + startCheckoutSessionFactory({ + 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() + }, + + deleteCheckoutSession: () => { + expect.fail() + }, + saveCheckoutSession: () => { + expect.fail() + } + })({ + workspaceId, + billingInterval: 'monthly', + workspacePlan: 'business', + workspaceSlug: cryptoRandomString({ length: 10 }) + }) + ) + expect(err.message).to.be.equal( + new WorkspaceCheckoutSessionInProgressError().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(() => @@ -108,6 +157,10 @@ describe('checkout @gatekeeper', () => { createCheckoutSession: () => { expect.fail() }, + + deleteCheckoutSession: () => { + expect.fail() + }, saveCheckoutSession: () => { expect.fail() } @@ -141,6 +194,9 @@ describe('checkout @gatekeeper', () => { getWorkspacePlan: async () => null, getWorkspaceCheckoutSession: async () => null, countRole: async () => 1, + deleteCheckoutSession: () => { + expect.fail() + }, createCheckoutSession: async () => checkoutSession, saveCheckoutSession: async ({ checkoutSession }) => { storedCheckoutSession = checkoutSession @@ -154,7 +210,7 @@ describe('checkout @gatekeeper', () => { expect(checkoutSession).deep.equal(storedCheckoutSession) expect(checkoutSession).deep.equal(createdCheckoutSession) }) - it('creates and stores a checkout for TRIAL and CANCELLED workspaces', async () => { + it('creates and stores a checkout for workspaces without a plan', async () => { const workspaceId = cryptoRandomString({ length: 10 }) const workspacePlan: PaidWorkspacePlans = 'pro' const billingInterval: WorkspacePlanBillingIntervals = 'monthly' @@ -173,6 +229,9 @@ describe('checkout @gatekeeper', () => { getWorkspacePlan: async () => null, getWorkspaceCheckoutSession: async () => null, countRole: async () => 1, + deleteCheckoutSession: () => { + expect.fail() + }, createCheckoutSession: async () => checkoutSession, saveCheckoutSession: async ({ checkoutSession }) => { storedCheckoutSession = checkoutSession @@ -186,6 +245,222 @@ describe('checkout @gatekeeper', () => { expect(checkoutSession).deep.equal(storedCheckoutSession) expect(checkoutSession).deep.equal(createdCheckoutSession) }) + + it('creates and stores a checkout for TRIAL workspaces', async () => { + const workspaceId = cryptoRandomString({ length: 10 }) + const workspacePlan: PaidWorkspacePlans = 'pro' + const billingInterval: WorkspacePlanBillingIntervals = 'monthly' + const checkoutSession: CheckoutSession = { + id: cryptoRandomString({ length: 10 }), + workspaceId, + workspacePlan, + url: 'https://example.com', + billingInterval, + paymentStatus: 'unpaid', + createdAt: new Date(), + updatedAt: new Date() + } + let storedCheckoutSession: CheckoutSession | undefined = undefined + const createdCheckoutSession = await startCheckoutSessionFactory({ + getWorkspacePlan: async () => ({ workspaceId, name: 'team', status: 'trial' }), + getWorkspaceCheckoutSession: async () => null, + countRole: async () => 1, + deleteCheckoutSession: () => { + expect.fail() + }, + 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 workspaces even if it has an old unpaid checkout session', async () => { + const workspaceId = cryptoRandomString({ length: 10 }) + const workspacePlan: PaidWorkspacePlans = 'pro' + const billingInterval: WorkspacePlanBillingIntervals = 'monthly' + const checkoutSession: CheckoutSession = { + id: cryptoRandomString({ length: 10 }), + workspaceId, + workspacePlan, + url: 'https://example.com', + billingInterval, + paymentStatus: 'unpaid', + createdAt: new Date(), + updatedAt: new Date() + } + let existingCheckoutSession: CheckoutSession | undefined = { + billingInterval, + id: cryptoRandomString({ length: 10 }), + createdAt: new Date(1990, 1, 12), + updatedAt: new Date(1990, 1, 12), + paymentStatus: 'unpaid', + url: 'https://example.com', + workspaceId, + workspacePlan + } + let storedCheckoutSession: CheckoutSession | undefined = undefined + const createdCheckoutSession = await startCheckoutSessionFactory({ + getWorkspacePlan: async () => ({ workspaceId, name: 'team', status: 'trial' }), + getWorkspaceCheckoutSession: async () => existingCheckoutSession!, + countRole: async () => 1, + deleteCheckoutSession: async () => { + existingCheckoutSession = undefined + }, + createCheckoutSession: async () => checkoutSession, + saveCheckoutSession: async ({ checkoutSession }) => { + storedCheckoutSession = checkoutSession + } + })({ + workspaceId, + billingInterval, + workspacePlan, + workspaceSlug: cryptoRandomString({ length: 10 }) + }) + expect(existingCheckoutSession).to.be.undefined + expect(checkoutSession).deep.equal(storedCheckoutSession) + expect(checkoutSession).deep.equal(createdCheckoutSession) + }) + + it('does not allow checkout for TRIAL workspaces if there is a paid checkout session', async () => { + const workspaceId = cryptoRandomString({ length: 10 }) + const workspacePlan: PaidWorkspacePlans = 'pro' + const billingInterval: WorkspacePlanBillingIntervals = 'monthly' + let existingCheckoutSession: CheckoutSession | undefined = { + billingInterval, + id: cryptoRandomString({ length: 10 }), + createdAt: new Date(1990, 1, 12), + updatedAt: new Date(1990, 1, 12), + paymentStatus: 'paid', + url: 'https://example.com', + workspaceId, + workspacePlan + } + const err = await expectToThrow(async () => { + await startCheckoutSessionFactory({ + getWorkspacePlan: async () => ({ + workspaceId, + name: 'team', + status: 'trial' + }), + getWorkspaceCheckoutSession: async () => existingCheckoutSession!, + countRole: async () => 1, + deleteCheckoutSession: async () => { + existingCheckoutSession = undefined + }, + createCheckoutSession: async () => { + expect.fail() + }, + saveCheckoutSession: async () => {} + })({ + workspaceId, + billingInterval, + workspacePlan, + workspaceSlug: cryptoRandomString({ length: 10 }) + }) + }) + expect(err.message).to.equal(new WorkspaceAlreadyPaidError().message) + }) + + it('does not allow checkout for TRIAL workspaces if there is a paid checkout session', async () => { + const workspaceId = cryptoRandomString({ length: 10 }) + const workspacePlan: PaidWorkspacePlans = 'pro' + const billingInterval: WorkspacePlanBillingIntervals = 'monthly' + let existingCheckoutSession: CheckoutSession | undefined = { + billingInterval, + id: cryptoRandomString({ length: 10 }), + createdAt: new Date(), + updatedAt: new Date(), + paymentStatus: 'unpaid', + url: 'https://example.com', + workspaceId, + workspacePlan + } + const err = await expectToThrow(async () => { + await startCheckoutSessionFactory({ + getWorkspacePlan: async () => ({ + workspaceId, + name: 'team', + status: 'trial' + }), + getWorkspaceCheckoutSession: async () => existingCheckoutSession!, + countRole: async () => 1, + deleteCheckoutSession: async () => { + existingCheckoutSession = undefined + }, + createCheckoutSession: async () => { + expect.fail() + }, + saveCheckoutSession: async () => {} + })({ + workspaceId, + billingInterval, + workspacePlan, + workspaceSlug: cryptoRandomString({ length: 10 }) + }) + }) + expect(err.message).to.equal( + new WorkspaceCheckoutSessionInProgressError().message + ) + }) + + it('creates and stores a checkout for 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 existingCheckoutSession: CheckoutSession | undefined = { + billingInterval: 'monthly', + id: cryptoRandomString({ length: 10 }), + paymentStatus: 'paid', + url: '', + workspaceId, + workspacePlan: 'business', + createdAt: new Date(), + updatedAt: new Date() + } + let storedCheckoutSession: CheckoutSession | undefined = undefined + const createdCheckoutSession = await startCheckoutSessionFactory({ + getWorkspacePlan: async () => ({ + name: 'pro', + workspaceId, + status: 'cancelled' + }), + getWorkspaceCheckoutSession: async () => existingCheckoutSession!, + countRole: async () => 1, + deleteCheckoutSession: async () => { + existingCheckoutSession = undefined + }, + createCheckoutSession: async () => checkoutSession, + saveCheckoutSession: async ({ checkoutSession }) => { + storedCheckoutSession = checkoutSession + } + })({ + workspaceId, + billingInterval, + workspacePlan, + workspaceSlug: cryptoRandomString({ length: 10 }) + }) + expect(existingCheckoutSession).to.be.undefined + 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 () => { From b9682577d7138ef6d5dffc860760118601d4b031 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Tue, 22 Oct 2024 10:31:56 +0200 Subject: [PATCH 32/48] feat(gatekeeper): add workspace plans gql api --- .../gatekeeper/typedefs/gatekeeper.graphql | 36 ++++++++++++ .../modules/core/graph/generated/graphql.ts | 57 +++++++++++++++++++ .../graph/generated/graphql.ts | 32 +++++++++++ .../modules/gatekeeper/clients/stripe.ts | 48 ++++++++++++++-- .../modules/gatekeeper/domain/billing.ts | 4 ++ .../gatekeeper/graph/resolvers/index.ts | 46 ++++++++++++++- .../gatekeeper/repositories/billing.ts | 13 ++++- .../server/modules/gatekeeper/rest/billing.ts | 40 +++++++++++-- .../server/test/graphql/generated/graphql.ts | 32 +++++++++++ 9 files changed, 298 insertions(+), 10 deletions(-) diff --git a/packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql b/packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql index 3e15473c2..13fc8a81f 100644 --- a/packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql +++ b/packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql @@ -47,3 +47,39 @@ type WorkspaceBillingMutations { createCheckoutSession(input: CheckoutSessionInput!): CheckoutSession! cancelCheckoutSession(input: CancelCheckoutSessionInput!): Boolean! } + +enum WorkspacePlans { + team + pro + business + unlimited + academia +} + +enum WorkspacePlanStatuses { + valid + paymentFailed + cancelled + trial +} + +type WorkspacePlan { + name: WorkspacePlans! + status: WorkspacePlanStatuses! +} + +type WorkspaceSubscription { + createdAt: DateTime! + updatedAt: DateTime! + currentBillingCycleEnd: DateTime! + billingInterval: BillingInterval! +} + +extend type Workspace { + # for now, this is nullable, cause existing workspaces have not been migrated to plans + # this doesn't need a special token scope + plan: WorkspacePlan + subscription: WorkspaceSubscription @hasScope(scope: "workspace:billing") + # this can only be created if there is an active subscription + customerPortalUrl: String @hasScope(scope: "workspace:billing") +} diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index 9382f1212..ac9580843 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -3939,6 +3939,7 @@ export type Workspace = { /** Billing data for Workspaces beta */ billing?: Maybe; createdAt: Scalars['DateTime']['output']; + customerPortalUrl?: Maybe; /** Selected fallback when `logo` not set */ defaultLogoIndex: Scalars['Int']['output']; /** The default role workspace members will receive for workspace projects. */ @@ -3956,10 +3957,12 @@ export type Workspace = { /** Logo image as base64-encoded string */ logo?: Maybe; name: Scalars['String']['output']; + plan?: Maybe; projects: ProjectCollection; /** Active user's role for this workspace. `null` if request is not authenticated, or the workspace is not explicitly shared with you. */ role?: Maybe; slug: Scalars['String']['output']; + subscription?: Maybe; team: WorkspaceCollaboratorCollection; updatedAt: Scalars['DateTime']['output']; }; @@ -4198,6 +4201,27 @@ export type WorkspaceMutationsUpdateRoleArgs = { input: WorkspaceRoleUpdateInput; }; +export type WorkspacePlan = { + __typename?: 'WorkspacePlan'; + name: WorkspacePlans; + status: WorkspacePlanStatuses; +}; + +export enum WorkspacePlanStatuses { + Cancelled = 'cancelled', + PaymentFailed = 'paymentFailed', + Trial = 'trial', + Valid = 'valid' +} + +export enum WorkspacePlans { + Academia = 'academia', + Business = 'business', + Pro = 'pro', + Team = 'team', + Unlimited = 'unlimited' +} + export type WorkspaceProjectInviteCreateInput = { /** Either this or userId must be filled */ email?: InputMaybe; @@ -4251,6 +4275,14 @@ export type WorkspaceRoleUpdateInput = { workspaceId: Scalars['String']['input']; }; +export type WorkspaceSubscription = { + __typename?: 'WorkspaceSubscription'; + billingInterval: BillingInterval; + createdAt: Scalars['DateTime']['output']; + currentBillingCycleEnd: Scalars['DateTime']['output']; + updatedAt: Scalars['DateTime']['output']; +}; + export type WorkspaceTeamFilter = { /** Limit team members to provided role(s) */ roles?: InputMaybe>; @@ -4607,12 +4639,16 @@ export type ResolversTypes = { WorkspaceInviteResendInput: WorkspaceInviteResendInput; WorkspaceInviteUseInput: WorkspaceInviteUseInput; WorkspaceMutations: ResolverTypeWrapper; + WorkspacePlan: ResolverTypeWrapper; + WorkspacePlanStatuses: WorkspacePlanStatuses; + WorkspacePlans: WorkspacePlans; WorkspaceProjectInviteCreateInput: WorkspaceProjectInviteCreateInput; WorkspaceProjectMutations: ResolverTypeWrapper; WorkspaceProjectsFilter: WorkspaceProjectsFilter; WorkspaceRole: WorkspaceRole; WorkspaceRoleDeleteInput: WorkspaceRoleDeleteInput; WorkspaceRoleUpdateInput: WorkspaceRoleUpdateInput; + WorkspaceSubscription: ResolverTypeWrapper; WorkspaceTeamFilter: WorkspaceTeamFilter; WorkspaceUpdateInput: WorkspaceUpdateInput; WorkspaceVersionsCount: ResolverTypeWrapper; @@ -4852,11 +4888,13 @@ export type ResolversParentTypes = { WorkspaceInviteResendInput: WorkspaceInviteResendInput; WorkspaceInviteUseInput: WorkspaceInviteUseInput; WorkspaceMutations: WorkspaceMutationsGraphQLReturn; + WorkspacePlan: WorkspacePlan; WorkspaceProjectInviteCreateInput: WorkspaceProjectInviteCreateInput; WorkspaceProjectMutations: WorkspaceProjectMutationsGraphQLReturn; WorkspaceProjectsFilter: WorkspaceProjectsFilter; WorkspaceRoleDeleteInput: WorkspaceRoleDeleteInput; WorkspaceRoleUpdateInput: WorkspaceRoleUpdateInput; + WorkspaceSubscription: WorkspaceSubscription; WorkspaceTeamFilter: WorkspaceTeamFilter; WorkspaceUpdateInput: WorkspaceUpdateInput; WorkspaceVersionsCount: WorkspaceVersionsCount; @@ -6221,6 +6259,7 @@ export type WebhookEventCollectionResolvers = { billing?: Resolver, ParentType, ContextType>; createdAt?: Resolver; + customerPortalUrl?: Resolver, ParentType, ContextType>; defaultLogoIndex?: Resolver; defaultProjectRole?: Resolver; description?: Resolver, ParentType, ContextType>; @@ -6231,9 +6270,11 @@ export type WorkspaceResolvers>, ParentType, ContextType, Partial>; logo?: Resolver, ParentType, ContextType>; name?: Resolver; + plan?: Resolver, ParentType, ContextType>; projects?: Resolver>; role?: Resolver, ParentType, ContextType>; slug?: Resolver; + subscription?: Resolver, ParentType, ContextType>; team?: Resolver>; updatedAt?: Resolver; __isTypeOf?: IsTypeOfResolverFn; @@ -6326,12 +6367,26 @@ export type WorkspaceMutationsResolvers; }; +export type WorkspacePlanResolvers = { + name?: Resolver; + status?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type WorkspaceProjectMutationsResolvers = { moveToWorkspace?: Resolver>; updateRole?: Resolver>; __isTypeOf?: IsTypeOfResolverFn; }; +export type WorkspaceSubscriptionResolvers = { + billingInterval?: Resolver; + createdAt?: Resolver; + currentBillingCycleEnd?: Resolver; + updatedAt?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type WorkspaceVersionsCountResolvers = { current?: Resolver; max?: Resolver; @@ -6480,7 +6535,9 @@ export type Resolvers = { WorkspaceDomain?: WorkspaceDomainResolvers; WorkspaceInviteMutations?: WorkspaceInviteMutationsResolvers; WorkspaceMutations?: WorkspaceMutationsResolvers; + WorkspacePlan?: WorkspacePlanResolvers; WorkspaceProjectMutations?: WorkspaceProjectMutationsResolvers; + WorkspaceSubscription?: WorkspaceSubscriptionResolvers; WorkspaceVersionsCount?: WorkspaceVersionsCountResolvers; }; 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 1d8a20ce5..e4f011109 100644 --- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts +++ b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts @@ -3922,6 +3922,7 @@ export type Workspace = { /** Billing data for Workspaces beta */ billing?: Maybe; createdAt: Scalars['DateTime']['output']; + customerPortalUrl?: Maybe; /** Selected fallback when `logo` not set */ defaultLogoIndex: Scalars['Int']['output']; /** The default role workspace members will receive for workspace projects. */ @@ -3939,10 +3940,12 @@ export type Workspace = { /** Logo image as base64-encoded string */ logo?: Maybe; name: Scalars['String']['output']; + plan?: Maybe; projects: ProjectCollection; /** Active user's role for this workspace. `null` if request is not authenticated, or the workspace is not explicitly shared with you. */ role?: Maybe; slug: Scalars['String']['output']; + subscription?: Maybe; team: WorkspaceCollaboratorCollection; updatedAt: Scalars['DateTime']['output']; }; @@ -4181,6 +4184,27 @@ export type WorkspaceMutationsUpdateRoleArgs = { input: WorkspaceRoleUpdateInput; }; +export type WorkspacePlan = { + __typename?: 'WorkspacePlan'; + name: WorkspacePlans; + status: WorkspacePlanStatuses; +}; + +export enum WorkspacePlanStatuses { + Cancelled = 'cancelled', + PaymentFailed = 'paymentFailed', + Trial = 'trial', + Valid = 'valid' +} + +export enum WorkspacePlans { + Academia = 'academia', + Business = 'business', + Pro = 'pro', + Team = 'team', + Unlimited = 'unlimited' +} + export type WorkspaceProjectInviteCreateInput = { /** Either this or userId must be filled */ email?: InputMaybe; @@ -4234,6 +4258,14 @@ export type WorkspaceRoleUpdateInput = { workspaceId: Scalars['String']['input']; }; +export type WorkspaceSubscription = { + __typename?: 'WorkspaceSubscription'; + billingInterval: BillingInterval; + createdAt: Scalars['DateTime']['output']; + currentBillingCycleEnd: Scalars['DateTime']['output']; + updatedAt: Scalars['DateTime']['output']; +}; + export type WorkspaceTeamFilter = { /** Limit team members to provided role(s) */ roles?: InputMaybe>; diff --git a/packages/server/modules/gatekeeper/clients/stripe.ts b/packages/server/modules/gatekeeper/clients/stripe.ts index 95d8f0bcd..d81fb58d5 100644 --- a/packages/server/modules/gatekeeper/clients/stripe.ts +++ b/packages/server/modules/gatekeeper/clients/stripe.ts @@ -15,6 +15,19 @@ type GetWorkspacePlanPrice = (args: { billingInterval: WorkspacePlanBillingIntervals }) => string +const getResultUrl = ({ + frontendOrigin, + workspaceId, + workspaceSlug +}: { + frontendOrigin: string + workspaceSlug: string + workspaceId: string +}) => + new URL( + `${frontendOrigin}/workspaces/${workspaceSlug}?workspace=${workspaceId}&settings=workspace/billing` + ) + export const createCheckoutSessionFactory = ({ stripe, @@ -34,10 +47,7 @@ export const createCheckoutSessionFactory = workspaceId }) => { //?settings=workspace/security& - const resultUrl = new URL( - `${frontendOrigin}/workspaces/${workspaceSlug}?workspace=${workspaceId}&settings=workspace/billing` - ) - + const resultUrl = getResultUrl({ frontendOrigin, workspaceId, workspaceSlug }) const price = getWorkspacePlanPrice({ billingInterval, workspacePlan }) const costLineItems: Stripe.Checkout.SessionCreateParams.LineItem[] = [ { price, quantity: seatCount } @@ -73,6 +83,36 @@ export const createCheckoutSessionFactory = } } +export const createCustomerPortalUrlFactory = + ({ + stripe, + frontendOrigin + }: // getWorkspacePlanPrice + { + stripe: Stripe + frontendOrigin: string + // getWorkspacePlanPrice: GetWorkspacePlanPrice + }) => + async ({ + workspaceId, + workspaceSlug, + customerId + }: { + customerId: string + workspaceId: string + workspaceSlug: string + }): Promise => { + const session = await stripe.billingPortal.sessions.create({ + customer: customerId, + return_url: getResultUrl({ + frontendOrigin, + workspaceId, + workspaceSlug + }).toString() + }) + return session.url + } + export const getSubscriptionDataFactory = ({ stripe diff --git a/packages/server/modules/gatekeeper/domain/billing.ts b/packages/server/modules/gatekeeper/domain/billing.ts index 0d3ab11cf..00b3787fa 100644 --- a/packages/server/modules/gatekeeper/domain/billing.ts +++ b/packages/server/modules/gatekeeper/domain/billing.ts @@ -130,6 +130,10 @@ export type SaveWorkspaceSubscription = (args: { workspaceSubscription: WorkspaceSubscription }) => Promise +export type GetWorkspaceSubscription = (args: { + workspaceId: string +}) => Promise + export type GetSubscriptionData = (args: { subscriptionId: string }) => Promise diff --git a/packages/server/modules/gatekeeper/graph/resolvers/index.ts b/packages/server/modules/gatekeeper/graph/resolvers/index.ts index db09deae9..8ac20c25e 100644 --- a/packages/server/modules/gatekeeper/graph/resolvers/index.ts +++ b/packages/server/modules/gatekeeper/graph/resolvers/index.ts @@ -9,13 +9,17 @@ import { } from '@/modules/workspaces/repositories/workspaces' import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace' import { db } from '@/db/knex' -import { createCheckoutSessionFactory } from '@/modules/gatekeeper/clients/stripe' +import { + createCheckoutSessionFactory, + createCustomerPortalUrlFactory +} from '@/modules/gatekeeper/clients/stripe' import { getWorkspacePlanPrice, stripe } from '@/modules/gatekeeper/stripe' import { startCheckoutSessionFactory } from '@/modules/gatekeeper/services/checkout' import { deleteCheckoutSessionFactory, getWorkspaceCheckoutSessionFactory, getWorkspacePlanFactory, + getWorkspaceSubscriptionFactory, saveCheckoutSessionFactory } from '@/modules/gatekeeper/repositories/billing' @@ -28,6 +32,45 @@ export = FF_GATEKEEPER_MODULE_ENABLED return pricingTable } }, + Workspace: { + plan: async (parent) => { + return await getWorkspacePlanFactory({ db })({ workspaceId: parent.id }) + }, + subscription: async (parent, _, ctx) => { + const workspaceId = parent.id + await authorizeResolver( + ctx.userId, + workspaceId, + Roles.Workspace.Admin, + ctx.resourceAccessRules + ) + return await getWorkspaceSubscriptionFactory({ db })({ workspaceId }) + }, + customerPortalUrl: async (parent, _, ctx) => { + const workspaceId = parent.id + await authorizeResolver( + ctx.userId, + workspaceId, + Roles.Workspace.Admin, + ctx.resourceAccessRules + ) + const workspaceSubscription = await getWorkspaceSubscriptionFactory({ db })({ + workspaceId + }) + if (!workspaceSubscription) return null + const workspace = await getWorkspaceFactory({ db })({ workspaceId }) + if (!workspace) + throw new Error('This cannot be, if there is a sub, there is a workspace') + return await createCustomerPortalUrlFactory({ + stripe, + frontendOrigin: getFrontendOrigin() + })({ + workspaceId: workspaceSubscription.workspaceId, + workspaceSlug: workspace.slug, + customerId: workspaceSubscription.subscriptionData.customerId + }) + } + }, WorkspaceMutations: () => ({}), WorkspaceBillingMutations: { cancelCheckoutSession: async (parent, args, ctx) => { @@ -74,6 +117,7 @@ export = FF_GATEKEEPER_MODULE_ENABLED workspacePlan, workspaceId, workspaceSlug: workspace.slug, + billingInterval }) diff --git a/packages/server/modules/gatekeeper/repositories/billing.ts b/packages/server/modules/gatekeeper/repositories/billing.ts index 93d7fa9bc..31e8aee35 100644 --- a/packages/server/modules/gatekeeper/repositories/billing.ts +++ b/packages/server/modules/gatekeeper/repositories/billing.ts @@ -10,7 +10,8 @@ import { WorkspacePlan, UpsertPaidWorkspacePlan, DeleteCheckoutSession, - GetWorkspaceCheckoutSession + GetWorkspaceCheckoutSession, + GetWorkspaceSubscription } from '@/modules/gatekeeper/domain/billing' import { Knex } from 'knex' @@ -99,3 +100,13 @@ export const saveWorkspaceSubscriptionFactory = async ({ workspaceSubscription }) => { await tables.workspaceSubscriptions(db).insert(workspaceSubscription) } + +export const getWorkspaceSubscriptionFactory = + ({ db }: { db: Knex }): GetWorkspaceSubscription => + async ({ workspaceId }) => { + const subscription = await tables + .workspaceSubscriptions(db) + .where({ workspaceId }) + .first() + return subscription || null + } diff --git a/packages/server/modules/gatekeeper/rest/billing.ts b/packages/server/modules/gatekeeper/rest/billing.ts index 631d316ee..2cdbbd210 100644 --- a/packages/server/modules/gatekeeper/rest/billing.ts +++ b/packages/server/modules/gatekeeper/rest/billing.ts @@ -24,6 +24,7 @@ import { import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace' import { createCheckoutSessionFactory, + createCustomerPortalUrlFactory, getSubscriptionDataFactory } from '@/modules/gatekeeper/clients/stripe' import { @@ -31,6 +32,7 @@ import { getCheckoutSessionFactory, getWorkspaceCheckoutSessionFactory, getWorkspacePlanFactory, + getWorkspaceSubscriptionFactory, saveCheckoutSessionFactory, saveWorkspaceSubscriptionFactory, updateCheckoutSessionStatusFactory, @@ -88,6 +90,40 @@ export const getBillingRouter = (): Router => { } ) + router.get( + '/api/v1/billing/workspaces/:workspaceId/customer-portal', + validateRequest({ + params: z.object({ + workspaceId: z.string().min(1) + }) + }), + async (req) => { + const { workspaceId } = req.params + await authorizeResolver( + req.context.userId, + workspaceId, + Roles.Workspace.Admin, + req.context.resourceAccessRules + ) + const workspaceSubscription = await getWorkspaceSubscriptionFactory({ db })({ + workspaceId + }) + if (!workspaceSubscription) return null + const workspace = await getWorkspaceFactory({ db })({ workspaceId }) + if (!workspace) + throw new Error('This cannot be, if there is a sub, there is a workspace') + const url = await createCustomerPortalUrlFactory({ + stripe, + frontendOrigin: getFrontendOrigin() + })({ + workspaceId: workspaceSubscription.workspaceId, + workspaceSlug: workspace.slug, + customerId: workspaceSubscription.subscriptionData.customerId + }) + return req.res?.redirect(url) + } + ) + router.post('/api/v1/billing/webhooks', async (req, res) => { const endpointSecret = getStripeEndpointSigningKey() const sig = req.headers['stripe-signature'] @@ -198,9 +234,5 @@ export const getBillingRouter = (): Router => { res.status(200).send('ok') }) - // prob needed when the checkout is cancelled - router.delete( - '/api/v1/billing/workspaces/:workspaceSlug/checkout-session/:workspacePlan' - ) return router } diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index fe757ac50..3eb146e63 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -3923,6 +3923,7 @@ export type Workspace = { /** Billing data for Workspaces beta */ billing?: Maybe; createdAt: Scalars['DateTime']['output']; + customerPortalUrl?: Maybe; /** Selected fallback when `logo` not set */ defaultLogoIndex: Scalars['Int']['output']; /** The default role workspace members will receive for workspace projects. */ @@ -3940,10 +3941,12 @@ export type Workspace = { /** Logo image as base64-encoded string */ logo?: Maybe; name: Scalars['String']['output']; + plan?: Maybe; projects: ProjectCollection; /** Active user's role for this workspace. `null` if request is not authenticated, or the workspace is not explicitly shared with you. */ role?: Maybe; slug: Scalars['String']['output']; + subscription?: Maybe; team: WorkspaceCollaboratorCollection; updatedAt: Scalars['DateTime']['output']; }; @@ -4182,6 +4185,27 @@ export type WorkspaceMutationsUpdateRoleArgs = { input: WorkspaceRoleUpdateInput; }; +export type WorkspacePlan = { + __typename?: 'WorkspacePlan'; + name: WorkspacePlans; + status: WorkspacePlanStatuses; +}; + +export enum WorkspacePlanStatuses { + Cancelled = 'cancelled', + PaymentFailed = 'paymentFailed', + Trial = 'trial', + Valid = 'valid' +} + +export enum WorkspacePlans { + Academia = 'academia', + Business = 'business', + Pro = 'pro', + Team = 'team', + Unlimited = 'unlimited' +} + export type WorkspaceProjectInviteCreateInput = { /** Either this or userId must be filled */ email?: InputMaybe; @@ -4235,6 +4259,14 @@ export type WorkspaceRoleUpdateInput = { workspaceId: Scalars['String']['input']; }; +export type WorkspaceSubscription = { + __typename?: 'WorkspaceSubscription'; + billingInterval: BillingInterval; + createdAt: Scalars['DateTime']['output']; + currentBillingCycleEnd: Scalars['DateTime']['output']; + updatedAt: Scalars['DateTime']['output']; +}; + export type WorkspaceTeamFilter = { /** Limit team members to provided role(s) */ roles?: InputMaybe>; From 50fff10acd7e88333c0cfff77124d1c8b6a15f45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Tue, 22 Oct 2024 23:17:22 +0200 Subject: [PATCH 33/48] feat(gatekeeper): handle cancelation and subscription updates --- .../lib/common/generated/gql/graphql.ts | 61 ++++ .../gatekeeper/typedefs/gatekeeper.graphql | 2 +- .../modules/core/graph/generated/graphql.ts | 2 +- .../graph/generated/graphql.ts | 2 +- .../modules/gatekeeper/clients/stripe.ts | 62 +++-- .../modules/gatekeeper/domain/billing.ts | 23 +- .../modules/gatekeeper/errors/billing.ts | 12 + .../gatekeeper/repositories/billing.ts | 27 +- .../server/modules/gatekeeper/rest/billing.ts | 25 +- .../modules/gatekeeper/services/checkout.ts | 16 +- .../gatekeeper/services/subscriptions.ts | 80 ++++++ .../intergration/billingRepositories.spec.ts | 105 +++++-- .../gatekeeper/tests/unit/checkout.spec.ts | 14 +- .../tests/unit/subscriptions.spec.ts | 262 ++++++++++++++++++ .../server/test/graphql/generated/graphql.ts | 2 +- 15 files changed, 621 insertions(+), 74 deletions(-) create mode 100644 packages/server/modules/gatekeeper/services/subscriptions.ts create mode 100644 packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts diff --git a/packages/frontend-2/lib/common/generated/gql/graphql.ts b/packages/frontend-2/lib/common/generated/gql/graphql.ts index 9cc28d4aa..9356b12e3 100644 --- a/packages/frontend-2/lib/common/generated/gql/graphql.ts +++ b/packages/frontend-2/lib/common/generated/gql/graphql.ts @@ -510,6 +510,11 @@ export type BranchUpdateInput = { streamId: Scalars['String']['input']; }; +export type CancelCheckoutSessionInput = { + sessionId: Scalars['ID']['input']; + workspaceId: Scalars['ID']['input']; +}; + export type CheckoutSession = { __typename?: 'CheckoutSession'; billingInterval: BillingInterval; @@ -3914,6 +3919,7 @@ export type Workspace = { /** Billing data for Workspaces beta */ billing?: Maybe; createdAt: Scalars['DateTime']['output']; + customerPortalUrl?: Maybe; /** Selected fallback when `logo` not set */ defaultLogoIndex: Scalars['Int']['output']; /** The default role workspace members will receive for workspace projects. */ @@ -3931,10 +3937,12 @@ export type Workspace = { /** Logo image as base64-encoded string */ logo?: Maybe; name: Scalars['String']['output']; + plan?: Maybe; projects: ProjectCollection; /** Active user's role for this workspace. `null` if request is not authenticated, or the workspace is not explicitly shared with you. */ role?: Maybe; slug: Scalars['String']['output']; + subscription?: Maybe; team: WorkspaceCollaboratorCollection; updatedAt: Scalars['DateTime']['output']; }; @@ -3966,10 +3974,16 @@ export type WorkspaceBilling = { export type WorkspaceBillingMutations = { __typename?: 'WorkspaceBillingMutations'; + cancelCheckoutSession: Scalars['Boolean']['output']; createCheckoutSession: CheckoutSession; }; +export type WorkspaceBillingMutationsCancelCheckoutSessionArgs = { + input: CancelCheckoutSessionInput; +}; + + export type WorkspaceBillingMutationsCreateCheckoutSessionArgs = { input: CheckoutSessionInput; }; @@ -4167,6 +4181,27 @@ export type WorkspaceMutationsUpdateRoleArgs = { input: WorkspaceRoleUpdateInput; }; +export type WorkspacePlan = { + __typename?: 'WorkspacePlan'; + name: WorkspacePlans; + status: WorkspacePlanStatuses; +}; + +export enum WorkspacePlanStatuses { + Canceled = 'canceled', + PaymentFailed = 'paymentFailed', + Trial = 'trial', + Valid = 'valid' +} + +export enum WorkspacePlans { + Academia = 'academia', + Business = 'business', + Pro = 'pro', + Team = 'team', + Unlimited = 'unlimited' +} + export type WorkspaceProjectInviteCreateInput = { /** Either this or userId must be filled */ email?: InputMaybe; @@ -4220,6 +4255,14 @@ export type WorkspaceRoleUpdateInput = { workspaceId: Scalars['String']['input']; }; +export type WorkspaceSubscription = { + __typename?: 'WorkspaceSubscription'; + billingInterval: BillingInterval; + createdAt: Scalars['DateTime']['output']; + currentBillingCycleEnd: Scalars['DateTime']['output']; + updatedAt: Scalars['DateTime']['output']; +}; + export type WorkspaceTeamFilter = { /** Limit team members to provided role(s) */ roles?: InputMaybe>; @@ -6283,7 +6326,9 @@ export type AllObjectTypes = { WorkspaceDomain: WorkspaceDomain, WorkspaceInviteMutations: WorkspaceInviteMutations, WorkspaceMutations: WorkspaceMutations, + WorkspacePlan: WorkspacePlan, WorkspaceProjectMutations: WorkspaceProjectMutations, + WorkspaceSubscription: WorkspaceSubscription, WorkspaceVersionsCount: WorkspaceVersionsCount, } export type ActiveUserMutationsFieldArgs = { @@ -7346,6 +7391,7 @@ export type WebhookEventCollectionFieldArgs = { export type WorkspaceFieldArgs = { billing: {}, createdAt: {}, + customerPortalUrl: {}, defaultLogoIndex: {}, defaultProjectRole: {}, description: {}, @@ -7356,9 +7402,11 @@ export type WorkspaceFieldArgs = { invitedTeam: WorkspaceInvitedTeamArgs, logo: {}, name: {}, + plan: {}, projects: WorkspaceProjectsArgs, role: {}, slug: {}, + subscription: {}, team: WorkspaceTeamArgs, updatedAt: {}, } @@ -7367,6 +7415,7 @@ export type WorkspaceBillingFieldArgs = { versionsCount: {}, } export type WorkspaceBillingMutationsFieldArgs = { + cancelCheckoutSession: WorkspaceBillingMutationsCancelCheckoutSessionArgs, createCheckoutSession: WorkspaceBillingMutationsCreateCheckoutSessionArgs, } export type WorkspaceCollaboratorFieldArgs = { @@ -7426,10 +7475,20 @@ export type WorkspaceMutationsFieldArgs = { update: WorkspaceMutationsUpdateArgs, updateRole: WorkspaceMutationsUpdateRoleArgs, } +export type WorkspacePlanFieldArgs = { + name: {}, + status: {}, +} export type WorkspaceProjectMutationsFieldArgs = { moveToWorkspace: WorkspaceProjectMutationsMoveToWorkspaceArgs, updateRole: WorkspaceProjectMutationsUpdateRoleArgs, } +export type WorkspaceSubscriptionFieldArgs = { + billingInterval: {}, + createdAt: {}, + currentBillingCycleEnd: {}, + updatedAt: {}, +} export type WorkspaceVersionsCountFieldArgs = { current: {}, max: {}, @@ -7571,7 +7630,9 @@ export type AllObjectFieldArgTypes = { WorkspaceDomain: WorkspaceDomainFieldArgs, WorkspaceInviteMutations: WorkspaceInviteMutationsFieldArgs, WorkspaceMutations: WorkspaceMutationsFieldArgs, + WorkspacePlan: WorkspacePlanFieldArgs, WorkspaceProjectMutations: WorkspaceProjectMutationsFieldArgs, + WorkspaceSubscription: WorkspaceSubscriptionFieldArgs, WorkspaceVersionsCount: WorkspaceVersionsCountFieldArgs, } diff --git a/packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql b/packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql index 13fc8a81f..a1aad81ab 100644 --- a/packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql +++ b/packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql @@ -59,7 +59,7 @@ enum WorkspacePlans { enum WorkspacePlanStatuses { valid paymentFailed - cancelled + canceled trial } diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index ac9580843..2a4e0cbcc 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -4208,7 +4208,7 @@ export type WorkspacePlan = { }; export enum WorkspacePlanStatuses { - Cancelled = 'cancelled', + Canceled = 'canceled', PaymentFailed = 'paymentFailed', Trial = 'trial', Valid = 'valid' 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 e4f011109..02de53edd 100644 --- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts +++ b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts @@ -4191,7 +4191,7 @@ export type WorkspacePlan = { }; export enum WorkspacePlanStatuses { - Cancelled = 'cancelled', + Canceled = 'canceled', PaymentFailed = 'paymentFailed', Trial = 'trial', Valid = 'valid' diff --git a/packages/server/modules/gatekeeper/clients/stripe.ts b/packages/server/modules/gatekeeper/clients/stripe.ts index d81fb58d5..cb61b692f 100644 --- a/packages/server/modules/gatekeeper/clients/stripe.ts +++ b/packages/server/modules/gatekeeper/clients/stripe.ts @@ -2,6 +2,7 @@ import { CreateCheckoutSession, GetSubscriptionData, + SubscriptionData, WorkspaceSubscription } from '@/modules/gatekeeper/domain/billing' import { @@ -67,7 +68,7 @@ export const createCheckoutSessionFactory = 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}` + cancel_url: `${resultUrl.toString()}&payment_status=canceled&session_id={CHECKOUT_SESSION_ID}` }) if (!session.url) throw new Error('Failed to create an active checkout session') @@ -123,33 +124,42 @@ export const getSubscriptionDataFactory = }): 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 - } - }) - } + return parseSubscriptionData(stripeSubscription) } +export const parseSubscriptionData = ( + stripeSubscription: Stripe.Subscription +): SubscriptionData => { + return { + customerId: + typeof stripeSubscription.customer === 'string' + ? stripeSubscription.customer + : stripeSubscription.customer.id, + subscriptionId: stripeSubscription.id, + status: stripeSubscription.status, + cancelAt: stripeSubscription.cancel_at + ? new Date(stripeSubscription.cancel_at) + : null, + 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 = diff --git a/packages/server/modules/gatekeeper/domain/billing.ts b/packages/server/modules/gatekeeper/domain/billing.ts index 00b3787fa..f26c2c39d 100644 --- a/packages/server/modules/gatekeeper/domain/billing.ts +++ b/packages/server/modules/gatekeeper/domain/billing.ts @@ -13,9 +13,10 @@ export type PaidWorkspacePlanStatuses = | UnpaidWorkspacePlanStatuses // | 'paymentNeeded' // unsure if this is needed | 'paymentFailed' - | 'cancelled' + | 'cancelationScheduled' + | 'canceled' -export type TrialWorkspacePlanStatuses = 'trial' +export type TrialWorkspacePlanStatuses = 'trial' | 'expired' type BaseWorkspacePlan = { workspaceId: string @@ -108,10 +109,20 @@ export type WorkspaceSubscription = { billingInterval: WorkspacePlanBillingIntervals subscriptionData: SubscriptionData } - export const subscriptionData = z.object({ subscriptionId: z.string().min(1), customerId: z.string().min(1), + cancelAt: z.date().nullable(), + status: z.union([ + z.literal('incomplete'), + z.literal('incomplete_expired'), + z.literal('trialing'), + z.literal('active'), + z.literal('past_due'), + z.literal('canceled'), + z.literal('unpaid'), + z.literal('paused') + ]), products: z .object({ // we're going to use the productId to match with our @@ -126,7 +137,7 @@ export const subscriptionData = z.object({ // this abstracts the stripe sub data export type SubscriptionData = z.infer -export type SaveWorkspaceSubscription = (args: { +export type UpsertWorkspaceSubscription = (args: { workspaceSubscription: WorkspaceSubscription }) => Promise @@ -134,6 +145,10 @@ export type GetWorkspaceSubscription = (args: { workspaceId: string }) => Promise +export type GetWorkspaceSubscriptionBySubscriptionId = (args: { + subscriptionId: string +}) => Promise + export type GetSubscriptionData = (args: { subscriptionId: string }) => Promise diff --git a/packages/server/modules/gatekeeper/errors/billing.ts b/packages/server/modules/gatekeeper/errors/billing.ts index e1e1ad630..3e49a07ad 100644 --- a/packages/server/modules/gatekeeper/errors/billing.ts +++ b/packages/server/modules/gatekeeper/errors/billing.ts @@ -6,6 +6,12 @@ export class WorkspacePlanNotFoundError extends BaseError { static statusCode = 500 } +export class WorkspacePlanMismatchError extends BaseError { + static defaultMessage = 'Workspace plan is not matching the expected state' + static code = 'WORKSPACE_PLAN_MISMATCH' + 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' @@ -23,3 +29,9 @@ export class CheckoutSessionNotFoundError extends BaseError { static code = 'CHECKOUT_SESSION_NOT_FOUND' static statusCode = 404 } + +export class WorkspaceSubscriptionNotFoundError extends BaseError { + static defaultMessage = 'Workspace subscription not found' + static code = 'WORKSPACE_SUBSCRIPTION_NOT_FOUND' + static statusCode = 404 +} diff --git a/packages/server/modules/gatekeeper/repositories/billing.ts b/packages/server/modules/gatekeeper/repositories/billing.ts index 31e8aee35..be31356fb 100644 --- a/packages/server/modules/gatekeeper/repositories/billing.ts +++ b/packages/server/modules/gatekeeper/repositories/billing.ts @@ -5,13 +5,14 @@ import { SaveCheckoutSession, UpdateCheckoutSessionStatus, UpsertWorkspacePlan, - SaveWorkspaceSubscription, + UpsertWorkspaceSubscription, WorkspaceSubscription, WorkspacePlan, UpsertPaidWorkspacePlan, DeleteCheckoutSession, GetWorkspaceCheckoutSession, - GetWorkspaceSubscription + GetWorkspaceSubscription, + GetWorkspaceSubscriptionBySubscriptionId } from '@/modules/gatekeeper/domain/billing' import { Knex } from 'knex' @@ -95,10 +96,14 @@ export const updateCheckoutSessionStatusFactory = .update({ paymentStatus, updatedAt: new Date() }) } -export const saveWorkspaceSubscriptionFactory = - ({ db }: { db: Knex }): SaveWorkspaceSubscription => +export const upsertWorkspaceSubscriptionFactory = + ({ db }: { db: Knex }): UpsertWorkspaceSubscription => async ({ workspaceSubscription }) => { - await tables.workspaceSubscriptions(db).insert(workspaceSubscription) + await tables + .workspaceSubscriptions(db) + .insert(workspaceSubscription) + .onConflict('workspaceId') + .merge() } export const getWorkspaceSubscriptionFactory = @@ -106,7 +111,19 @@ export const getWorkspaceSubscriptionFactory = async ({ workspaceId }) => { const subscription = await tables .workspaceSubscriptions(db) + .select() .where({ workspaceId }) .first() return subscription || null } + +export const getWorkspaceSubscriptionBySubscriptionIdFactory = + ({ db }: { db: Knex }): GetWorkspaceSubscriptionBySubscriptionId => + async ({ subscriptionId }) => { + const subscription = await tables + .workspaceSubscriptions(db) + .select() + .whereRaw(`"subscriptionData" ->> 'subscriptionId' = ?`, [subscriptionId]) + .first() + return subscription ?? null + } diff --git a/packages/server/modules/gatekeeper/rest/billing.ts b/packages/server/modules/gatekeeper/rest/billing.ts index 2cdbbd210..e89075531 100644 --- a/packages/server/modules/gatekeeper/rest/billing.ts +++ b/packages/server/modules/gatekeeper/rest/billing.ts @@ -25,7 +25,8 @@ import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace' import { createCheckoutSessionFactory, createCustomerPortalUrlFactory, - getSubscriptionDataFactory + getSubscriptionDataFactory, + parseSubscriptionData } from '@/modules/gatekeeper/clients/stripe' import { deleteCheckoutSessionFactory, @@ -34,13 +35,15 @@ import { getWorkspacePlanFactory, getWorkspaceSubscriptionFactory, saveCheckoutSessionFactory, - saveWorkspaceSubscriptionFactory, + upsertWorkspaceSubscriptionFactory, updateCheckoutSessionStatusFactory, - upsertPaidWorkspacePlanFactory + upsertPaidWorkspacePlanFactory, + getWorkspaceSubscriptionBySubscriptionIdFactory } from '@/modules/gatekeeper/repositories/billing' import { WorkspaceAlreadyPaidError } from '@/modules/gatekeeper/errors/billing' import { withTransaction } from '@/modules/shared/helpers/dbHelper' import { stripe, getWorkspacePlanPrice } from '@/modules/gatekeeper/stripe' +import { handleSubscriptionUpdateFactory } from '@/modules/gatekeeper/services/subscriptions' export const getBillingRouter = (): Router => { const router = Router() @@ -189,7 +192,9 @@ export const getBillingRouter = (): Router => { db: trx }), upsertPaidWorkspacePlan: upsertPaidWorkspacePlanFactory({ db: trx }), - saveWorkspaceSubscription: saveWorkspaceSubscriptionFactory({ db: trx }), + upsertWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({ + db: trx + }), getSubscriptionData: getSubscriptionDataFactory({ stripe }) @@ -227,6 +232,18 @@ export const getBillingRouter = (): Router => { }) break + case 'customer.subscription.updated': + case 'customer.subscription.deleted': + await handleSubscriptionUpdateFactory({ + getWorkspacePlan: getWorkspacePlanFactory({ db }), + upsertPaidWorkspacePlan: upsertPaidWorkspacePlanFactory({ db }), + getWorkspaceSubscriptionBySubscriptionId: + getWorkspaceSubscriptionBySubscriptionIdFactory({ db }), + upsertWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({ db }) + })({ subscriptionData: parseSubscriptionData(event.data.object) }) + + break + default: break } diff --git a/packages/server/modules/gatekeeper/services/checkout.ts b/packages/server/modules/gatekeeper/services/checkout.ts index c84f3cc4a..1aa91843d 100644 --- a/packages/server/modules/gatekeeper/services/checkout.ts +++ b/packages/server/modules/gatekeeper/services/checkout.ts @@ -5,7 +5,7 @@ import { GetWorkspacePlan, SaveCheckoutSession, UpdateCheckoutSessionStatus, - SaveWorkspaceSubscription, + UpsertWorkspaceSubscription, UpsertPaidWorkspacePlan, GetSubscriptionData, GetWorkspaceCheckoutSession, @@ -58,12 +58,13 @@ export const startCheckoutSessionFactory = 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 + // valid and paymentFailed, but not canceled status is not something we need a checkout for // we already have their credit card info case 'valid': case 'paymentFailed': + case 'cancelationScheduled': throw new WorkspaceAlreadyPaidError() - case 'cancelled': + case 'canceled': const existingCheckoutSession = await getWorkspaceCheckoutSession({ workspaceId }) @@ -73,9 +74,10 @@ export const startCheckoutSessionFactory = }) break - // maybe, we can reactivate cancelled plans via the sub in stripe, but this is fine too + // maybe, we can reactivate canceled plans via the sub in stripe, but this is fine too // it will create a new customer and a new sub though, the reactivation would use the existing customer case 'trial': + case 'expired': // 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 @@ -127,13 +129,13 @@ export const completeCheckoutSessionFactory = ({ getCheckoutSession, updateCheckoutSessionStatus, - saveWorkspaceSubscription, + upsertWorkspaceSubscription, upsertPaidWorkspacePlan, getSubscriptionData }: { getCheckoutSession: GetCheckoutSession updateCheckoutSessionStatus: UpdateCheckoutSessionStatus - saveWorkspaceSubscription: SaveWorkspaceSubscription + upsertWorkspaceSubscription: UpsertWorkspaceSubscription upsertPaidWorkspacePlan: UpsertPaidWorkspacePlan getSubscriptionData: GetSubscriptionData }) => @@ -195,7 +197,7 @@ export const completeCheckoutSessionFactory = subscriptionData } - await saveWorkspaceSubscription({ + await upsertWorkspaceSubscription({ workspaceSubscription }) } diff --git a/packages/server/modules/gatekeeper/services/subscriptions.ts b/packages/server/modules/gatekeeper/services/subscriptions.ts new file mode 100644 index 000000000..1d2553632 --- /dev/null +++ b/packages/server/modules/gatekeeper/services/subscriptions.ts @@ -0,0 +1,80 @@ +import { + GetWorkspacePlan, + GetWorkspaceSubscriptionBySubscriptionId, + PaidWorkspacePlanStatuses, + SubscriptionData, + UpsertPaidWorkspacePlan, + UpsertWorkspaceSubscription +} from '@/modules/gatekeeper/domain/billing' +import { + WorkspacePlanMismatchError, + WorkspacePlanNotFoundError, + WorkspaceSubscriptionNotFoundError +} from '@/modules/gatekeeper/errors/billing' +import { throwUncoveredError } from '@speckle/shared' + +export const handleSubscriptionUpdateFactory = + ({ + upsertPaidWorkspacePlan, + getWorkspacePlan, + getWorkspaceSubscriptionBySubscriptionId, + upsertWorkspaceSubscription + }: { + getWorkspacePlan: GetWorkspacePlan + upsertPaidWorkspacePlan: UpsertPaidWorkspacePlan + getWorkspaceSubscriptionBySubscriptionId: GetWorkspaceSubscriptionBySubscriptionId + upsertWorkspaceSubscription: UpsertWorkspaceSubscription + }) => + async ({ subscriptionData }: { subscriptionData: SubscriptionData }) => { + // we're only handling marking the sub scheduled for cancelation right now + const subscription = await getWorkspaceSubscriptionBySubscriptionId({ + subscriptionId: subscriptionData.subscriptionId + }) + if (!subscription) throw new WorkspaceSubscriptionNotFoundError() + + const workspacePlan = await getWorkspacePlan({ + workspaceId: subscription.workspaceId + }) + if (!workspacePlan) throw new WorkspacePlanNotFoundError() + + let status: PaidWorkspacePlanStatuses | undefined = undefined + + if ( + subscriptionData.status === 'active' && + subscriptionData.cancelAt && + subscriptionData.cancelAt > new Date() + ) { + status = 'cancelationScheduled' + } else if ( + subscriptionData.status === 'active' && + subscriptionData.cancelAt === null + ) { + status = 'valid' + } else if (subscriptionData.status === 'past_due') { + status = 'paymentFailed' + } else if (subscriptionData.status === 'canceled') { + status = 'canceled' + } + + if (status) { + switch (workspacePlan.name) { + case 'team': + case 'pro': + case 'business': + break + case 'unlimited': + case 'academia': + throw new WorkspacePlanMismatchError() + default: + throwUncoveredError(workspacePlan) + } + + await upsertPaidWorkspacePlan({ + workspacePlan: { ...workspacePlan, status } + }) + // if there is a status in the sub, we recognize, we need to update our state + await upsertWorkspaceSubscription({ + workspaceSubscription: { ...subscription, subscriptionData } + }) + } + } diff --git a/packages/server/modules/gatekeeper/tests/intergration/billingRepositories.spec.ts b/packages/server/modules/gatekeeper/tests/intergration/billingRepositories.spec.ts index 7d3d0c366..013cb2db9 100644 --- a/packages/server/modules/gatekeeper/tests/intergration/billingRepositories.spec.ts +++ b/packages/server/modules/gatekeeper/tests/intergration/billingRepositories.spec.ts @@ -1,13 +1,16 @@ import db from '@/db/knex' +import { WorkspaceSubscription } from '@/modules/gatekeeper/domain/billing' import { deleteCheckoutSessionFactory, getCheckoutSessionFactory, getWorkspaceCheckoutSessionFactory, getWorkspacePlanFactory, saveCheckoutSessionFactory, - saveWorkspaceSubscriptionFactory, + upsertWorkspaceSubscriptionFactory, updateCheckoutSessionStatusFactory, - upsertPaidWorkspacePlanFactory + upsertPaidWorkspacePlanFactory, + getWorkspaceSubscriptionFactory, + getWorkspaceSubscriptionBySubscriptionIdFactory } from '@/modules/gatekeeper/repositories/billing' import { upsertWorkspaceFactory } from '@/modules/workspaces/repositories/workspaces' import { createAndStoreTestWorkspaceFactory } from '@/test/speckle-helpers/workspaces' @@ -25,7 +28,10 @@ const deleteCheckoutSession = deleteCheckoutSessionFactory({ db }) const getCheckoutSession = getCheckoutSessionFactory({ db }) const getWorkspaceCheckoutSession = getWorkspaceCheckoutSessionFactory({ db }) const updateCheckoutSessionStatus = updateCheckoutSessionStatusFactory({ db }) -const saveWorkspaceSubscription = saveWorkspaceSubscriptionFactory({ db }) +const upsertWorkspaceSubscription = upsertWorkspaceSubscriptionFactory({ db }) +const getWorkspaceSubscription = getWorkspaceSubscriptionFactory({ db }) +const getWorkspaceSubscriptionBySubscriptionId = + getWorkspaceSubscriptionBySubscriptionIdFactory({ db }) describe('billing repositories @gatekeeper', () => { describe('workspacePlans', () => { @@ -194,24 +200,87 @@ describe('billing repositories @gatekeeper', () => { }) }) describe('workspaceSubscriptions', () => { - describe('saveWorkspaceSubscription creates a function, that', () => { - it('saves the subscription', async () => { + describe('upsertWorkspaceSubscription creates a function, that', () => { + it('saves and updates 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 - } + const workspaceSubscription: WorkspaceSubscription = { + billingInterval: 'monthly' as const, + createdAt: new Date(), + updatedAt: new Date(), + currentBillingCycleEnd: new Date(), + subscriptionData: { + customerId: cryptoRandomString({ length: 10 }), + status: 'active' as const, + cancelAt: null, + products: [ + { + priceId: cryptoRandomString({ length: 10 }), + quantity: 10, + productId: cryptoRandomString({ length: 10 }), + subscriptionItemId: cryptoRandomString({ length: 10 }) + } + ], + subscriptionId: cryptoRandomString({ length: 10 }) + }, + workspaceId + } + await upsertWorkspaceSubscription({ workspaceSubscription }) + let storedSubscription = await getWorkspaceSubscription({ workspaceId }) + expect(storedSubscription).deep.equal(workspaceSubscription) + workspaceSubscription.billingInterval = 'yearly' + workspaceSubscription.subscriptionData.products[0].quantity = 3 + + await upsertWorkspaceSubscription({ workspaceSubscription }) + storedSubscription = await getWorkspaceSubscription({ workspaceId }) + expect(storedSubscription).deep.equal(workspaceSubscription) + }) + }) + describe('getWorkspaceSubscriptionFactory creates a function, that', () => { + it('returns null if the subscription is not found', async () => { + const sub = await getWorkspaceSubscription({ + workspaceId: cryptoRandomString({ length: 10 }) }) + expect(sub).to.be.null + }) + }) + + describe('getWorkspaceSubscriptionBySubscriptionIdFactory creates a function, that', () => { + it('returns null if the subscription is not found', async () => { + const sub = await getWorkspaceSubscriptionBySubscriptionId({ + subscriptionId: cryptoRandomString({ length: 10 }) + }) + expect(sub).to.be.null + }) + it('returns the sub', async () => { + const workspace = await createAndStoreTestWorkspace() + const workspaceId = workspace.id + const workspaceSubscription: WorkspaceSubscription = { + billingInterval: 'monthly' as const, + createdAt: new Date(), + updatedAt: new Date(), + currentBillingCycleEnd: new Date(), + subscriptionData: { + customerId: cryptoRandomString({ length: 10 }), + status: 'active' as const, + cancelAt: null, + products: [ + { + priceId: cryptoRandomString({ length: 10 }), + quantity: 10, + productId: cryptoRandomString({ length: 10 }), + subscriptionItemId: cryptoRandomString({ length: 10 }) + } + ], + subscriptionId: cryptoRandomString({ length: 10 }) + }, + workspaceId + } + await upsertWorkspaceSubscription({ workspaceSubscription }) + const storedSubscription = await getWorkspaceSubscriptionBySubscriptionId({ + subscriptionId: workspaceSubscription.subscriptionData.subscriptionId + }) + expect(storedSubscription).deep.equal(workspaceSubscription) }) }) }) diff --git a/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts b/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts index 308cee180..cd2c118ee 100644 --- a/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts +++ b/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts @@ -411,7 +411,7 @@ describe('checkout @gatekeeper', () => { ) }) - it('creates and stores a checkout for CANCELLED workspaces', async () => { + it('creates and stores a checkout for CANCELED workspaces', async () => { const workspaceId = cryptoRandomString({ length: 10 }) const workspacePlan: PaidWorkspacePlans = 'pro' const billingInterval: WorkspacePlanBillingIntervals = 'monthly' @@ -440,7 +440,7 @@ describe('checkout @gatekeeper', () => { getWorkspacePlan: async () => ({ name: 'pro', workspaceId, - status: 'cancelled' + status: 'canceled' }), getWorkspaceCheckoutSession: async () => existingCheckoutSession!, countRole: async () => 1, @@ -479,7 +479,7 @@ describe('checkout @gatekeeper', () => { getSubscriptionData: async () => { expect.fail() }, - saveWorkspaceSubscription: async () => { + upsertWorkspaceSubscription: async () => { expect.fail() } })({ sessionId, subscriptionId }) @@ -511,7 +511,7 @@ describe('checkout @gatekeeper', () => { getSubscriptionData: async () => { expect.fail() }, - saveWorkspaceSubscription: async () => { + upsertWorkspaceSubscription: async () => { expect.fail() } })({ sessionId, subscriptionId }) @@ -547,7 +547,9 @@ describe('checkout @gatekeeper', () => { quantity: 10, subscriptionItemId: cryptoRandomString({ length: 10 }) } - ] + ], + status: 'active', + cancelAt: null } let storedWorkspaceSubscriptionData: WorkspaceSubscription | undefined = @@ -562,7 +564,7 @@ describe('checkout @gatekeeper', () => { storedWorkspacePlan = workspacePlan }, getSubscriptionData: async () => subscriptionData, - saveWorkspaceSubscription: async ({ workspaceSubscription }) => { + upsertWorkspaceSubscription: async ({ workspaceSubscription }) => { storedWorkspaceSubscriptionData = workspaceSubscription } })({ sessionId, subscriptionId }) diff --git a/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts b/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts new file mode 100644 index 000000000..1066bfadb --- /dev/null +++ b/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts @@ -0,0 +1,262 @@ +import { + SubscriptionData, + WorkspacePlan, + WorkspaceSubscription +} from '@/modules/gatekeeper/domain/billing' +import { + WorkspacePlanMismatchError, + WorkspacePlanNotFoundError, + WorkspaceSubscriptionNotFoundError +} from '@/modules/gatekeeper/errors/billing' +import { handleSubscriptionUpdateFactory } from '@/modules/gatekeeper/services/subscriptions' +import { expectToThrow } from '@/test/assertionHelper' +import { expect } from 'chai' +import cryptoRandomString from 'crypto-random-string' +import { merge } from 'lodash' + +const createTestSubscriptionData = ( + overrides: Partial = {} +): SubscriptionData => { + const defaultValues: SubscriptionData = { + cancelAt: null, + customerId: cryptoRandomString({ length: 10 }), + products: [ + { + priceId: cryptoRandomString({ length: 10 }), + productId: cryptoRandomString({ length: 10 }), + quantity: 3, + subscriptionItemId: cryptoRandomString({ length: 10 }) + } + ], + status: 'active', + subscriptionId: cryptoRandomString({ length: 10 }) + } + return merge(defaultValues, overrides) +} + +describe('subscriptions @gatekeeper', () => { + describe('handleSubscriptionUpdateFactory creates a function, that', () => { + it('throws if subscription is not found', async () => { + const subscriptionData = createTestSubscriptionData() + const err = await expectToThrow(async () => { + await handleSubscriptionUpdateFactory({ + getWorkspaceSubscriptionBySubscriptionId: async () => null, + getWorkspacePlan: async () => { + expect.fail() + }, + upsertWorkspaceSubscription: async () => { + expect.fail() + }, + upsertPaidWorkspacePlan: async () => { + expect.fail() + } + })({ subscriptionData }) + }) + expect(err.message).to.equal(new WorkspaceSubscriptionNotFoundError().message) + }) + it('throws if workspacePlan is not found', async () => { + const subscriptionData = createTestSubscriptionData() + const err = await expectToThrow(async () => { + await handleSubscriptionUpdateFactory({ + getWorkspaceSubscriptionBySubscriptionId: async () => ({ + subscriptionData, + billingInterval: 'monthly', + createdAt: new Date(), + updatedAt: new Date(), + currentBillingCycleEnd: new Date(), + workspaceId: cryptoRandomString({ length: 10 }) + }), + getWorkspacePlan: async () => null, + upsertWorkspaceSubscription: async () => { + expect.fail() + }, + upsertPaidWorkspacePlan: async () => { + expect.fail() + } + })({ subscriptionData }) + }) + expect(err.message).to.equal(new WorkspacePlanNotFoundError().message) + }) + ;(['unlimited', 'academia'] as const).forEach((name) => + it(`throws for non paid workspace plan: ${name}`, async () => { + const subscriptionData = createTestSubscriptionData() + const workspaceId = cryptoRandomString({ length: 10 }) + const err = await expectToThrow(async () => { + await handleSubscriptionUpdateFactory({ + getWorkspaceSubscriptionBySubscriptionId: async () => ({ + subscriptionData, + billingInterval: 'monthly', + createdAt: new Date(), + updatedAt: new Date(), + currentBillingCycleEnd: new Date(), + workspaceId + }), + getWorkspacePlan: async () => ({ name, workspaceId, status: 'valid' }), + upsertWorkspaceSubscription: async () => { + expect.fail() + }, + upsertPaidWorkspacePlan: async () => { + expect.fail() + } + })({ subscriptionData }) + }) + expect(err.message).to.equal(new WorkspacePlanMismatchError().message) + }) + ) + it('sets the state to cancelationScheduled', async () => { + const subscriptionData = createTestSubscriptionData({ + status: 'active', + cancelAt: new Date(2099, 12, 31) + }) + const workspaceId = cryptoRandomString({ length: 10 }) + const workspaceSubscription = { + subscriptionData, + billingInterval: 'monthly' as const, + createdAt: new Date(), + updatedAt: new Date(), + currentBillingCycleEnd: new Date(), + workspaceId + } + + let updatedSubscription: WorkspaceSubscription | undefined = undefined + let updatedPlan: WorkspacePlan | undefined = undefined + + await handleSubscriptionUpdateFactory({ + getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription, + getWorkspacePlan: async () => ({ name: 'team', workspaceId, status: 'trial' }), + upsertWorkspaceSubscription: async ({ workspaceSubscription }) => { + updatedSubscription = workspaceSubscription + }, + upsertPaidWorkspacePlan: async ({ workspacePlan }) => { + updatedPlan = workspacePlan + } + })({ subscriptionData }) + expect(updatedPlan!.status).to.be.equal('cancelationScheduled') + expect(updatedSubscription).deep.equal(workspaceSubscription) + }) + it('sets the state to valid', async () => { + const subscriptionData = createTestSubscriptionData({ + status: 'active', + cancelAt: null + }) + const workspaceId = cryptoRandomString({ length: 10 }) + const workspaceSubscription = { + subscriptionData, + billingInterval: 'monthly' as const, + createdAt: new Date(), + updatedAt: new Date(), + currentBillingCycleEnd: new Date(), + workspaceId + } + + let updatedSubscription: WorkspaceSubscription | undefined = undefined + let updatedPlan: WorkspacePlan | undefined = undefined + + await handleSubscriptionUpdateFactory({ + getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription, + getWorkspacePlan: async () => ({ name: 'team', workspaceId, status: 'trial' }), + upsertWorkspaceSubscription: async ({ workspaceSubscription }) => { + updatedSubscription = workspaceSubscription + }, + upsertPaidWorkspacePlan: async ({ workspacePlan }) => { + updatedPlan = workspacePlan + } + })({ subscriptionData }) + expect(updatedPlan!.status).to.be.equal('valid') + expect(updatedSubscription).deep.equal(workspaceSubscription) + }) + it('sets the state to paymentFailed', async () => { + const subscriptionData = createTestSubscriptionData({ + status: 'past_due' + }) + const workspaceId = cryptoRandomString({ length: 10 }) + const workspaceSubscription = { + subscriptionData, + billingInterval: 'monthly' as const, + createdAt: new Date(), + updatedAt: new Date(), + currentBillingCycleEnd: new Date(), + workspaceId + } + + let updatedSubscription: WorkspaceSubscription | undefined = undefined + let updatedPlan: WorkspacePlan | undefined = undefined + + await handleSubscriptionUpdateFactory({ + getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription, + getWorkspacePlan: async () => ({ name: 'team', workspaceId, status: 'trial' }), + upsertWorkspaceSubscription: async ({ workspaceSubscription }) => { + updatedSubscription = workspaceSubscription + }, + upsertPaidWorkspacePlan: async ({ workspacePlan }) => { + updatedPlan = workspacePlan + } + })({ subscriptionData }) + expect(updatedPlan!.status).to.be.equal('paymentFailed') + expect(updatedSubscription).deep.equal(workspaceSubscription) + }) + it('sets the state to canceled', async () => { + const subscriptionData = createTestSubscriptionData({ + status: 'canceled' + }) + const workspaceId = cryptoRandomString({ length: 10 }) + const workspaceSubscription = { + subscriptionData, + billingInterval: 'monthly' as const, + createdAt: new Date(), + updatedAt: new Date(), + currentBillingCycleEnd: new Date(), + workspaceId + } + + let updatedSubscription: WorkspaceSubscription | undefined = undefined + let updatedPlan: WorkspacePlan | undefined = undefined + + await handleSubscriptionUpdateFactory({ + getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription, + getWorkspacePlan: async () => ({ name: 'team', workspaceId, status: 'trial' }), + upsertWorkspaceSubscription: async ({ workspaceSubscription }) => { + updatedSubscription = workspaceSubscription + }, + upsertPaidWorkspacePlan: async ({ workspacePlan }) => { + updatedPlan = workspacePlan + } + })({ subscriptionData }) + expect(updatedPlan!.status).to.be.equal('canceled') + expect(updatedSubscription).deep.equal(workspaceSubscription) + }) + ;( + ['incomplete', 'incomplete_expired', 'trialing', 'unpaid', 'paused'] as const + ).forEach((status) => { + it(`does not update the plan or the subscription in case of an unhandled status: ${status}`, async () => { + const subscriptionData = createTestSubscriptionData({ + status + }) + const workspaceId = cryptoRandomString({ length: 10 }) + const workspaceSubscription = { + subscriptionData, + billingInterval: 'monthly' as const, + createdAt: new Date(), + updatedAt: new Date(), + currentBillingCycleEnd: new Date(), + workspaceId + } + + await handleSubscriptionUpdateFactory({ + getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription, + getWorkspacePlan: async () => ({ + name: 'team', + workspaceId, + status: 'trial' + }), + upsertWorkspaceSubscription: async () => { + expect.fail() + }, + upsertPaidWorkspacePlan: async () => { + expect.fail() + } + })({ subscriptionData }) + }) + }) + }) +}) diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index 3eb146e63..65ddec8a3 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -4192,7 +4192,7 @@ export type WorkspacePlan = { }; export enum WorkspacePlanStatuses { - Cancelled = 'cancelled', + Canceled = 'canceled', PaymentFailed = 'paymentFailed', Trial = 'trial', Valid = 'valid' From 4ee7c4bc3d4d010bd84810fe4a64120ff5965556 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Tue, 22 Oct 2024 23:36:46 +0200 Subject: [PATCH 34/48] fix(gatekeeper): scope initialization --- packages/server/modules/gatekeeper/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/modules/gatekeeper/index.ts b/packages/server/modules/gatekeeper/index.ts index 41dc6a677..ba6cf40c9 100644 --- a/packages/server/modules/gatekeeper/index.ts +++ b/packages/server/modules/gatekeeper/index.ts @@ -17,6 +17,7 @@ const initScopes = async () => { const gatekeeperModule: SpeckleModule = { async init(app, isInitial) { + await initScopes() if (!FF_GATEKEEPER_MODULE_ENABLED) return const isLicenseValid = await validateModuleLicense({ @@ -32,7 +33,6 @@ const gatekeeperModule: SpeckleModule = { 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) { - await initScopes() app.use(getBillingRouter()) const isLicenseValid = await validateModuleLicense({ From 07be0e6b506a7df8f33f3260a99b575ea6489df6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Wed, 23 Oct 2024 08:27:31 +0200 Subject: [PATCH 35/48] fix(gatekeeper): eliminate stripe client import sideeffect --- packages/server/modules/gatekeeper/rest/billing.ts | 6 ++++-- packages/server/modules/gatekeeper/stripe.ts | 7 ++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/server/modules/gatekeeper/rest/billing.ts b/packages/server/modules/gatekeeper/rest/billing.ts index e89075531..ddec622da 100644 --- a/packages/server/modules/gatekeeper/rest/billing.ts +++ b/packages/server/modules/gatekeeper/rest/billing.ts @@ -42,7 +42,7 @@ import { } from '@/modules/gatekeeper/repositories/billing' import { WorkspaceAlreadyPaidError } from '@/modules/gatekeeper/errors/billing' import { withTransaction } from '@/modules/shared/helpers/dbHelper' -import { stripe, getWorkspacePlanPrice } from '@/modules/gatekeeper/stripe' +import { getStripeClient, getWorkspacePlanPrice } from '@/modules/gatekeeper/stripe' import { handleSubscriptionUpdateFactory } from '@/modules/gatekeeper/services/subscriptions' export const getBillingRouter = (): Router => { @@ -73,7 +73,7 @@ export const getBillingRouter = (): Router => { ) const createCheckoutSession = createCheckoutSessionFactory({ - stripe, + stripe: getStripeClient(), frontendOrigin: getFrontendOrigin(), getWorkspacePlanPrice }) @@ -115,6 +115,7 @@ export const getBillingRouter = (): Router => { const workspace = await getWorkspaceFactory({ db })({ workspaceId }) if (!workspace) throw new Error('This cannot be, if there is a sub, there is a workspace') + const stripe = getStripeClient() const url = await createCustomerPortalUrlFactory({ stripe, frontendOrigin: getFrontendOrigin() @@ -135,6 +136,7 @@ export const getBillingRouter = (): Router => { return } + const stripe = getStripeClient() let event: Stripe.Event try { diff --git a/packages/server/modules/gatekeeper/stripe.ts b/packages/server/modules/gatekeeper/stripe.ts index b5c13f05a..4fa6cd597 100644 --- a/packages/server/modules/gatekeeper/stripe.ts +++ b/packages/server/modules/gatekeeper/stripe.ts @@ -6,7 +6,12 @@ import { import { getStringFromEnv, getStripeApiKey } from '@/modules/shared/helpers/envHelper' import { Stripe } from 'stripe' -export const stripe = new Stripe(getStripeApiKey(), { typescript: true }) +let stripeClient: Stripe | undefined = undefined + +export const getStripeClient = () => { + if (!stripeClient) stripeClient = new Stripe(getStripeApiKey(), { typescript: true }) + return stripeClient +} export const workspacePlanPrices = (): Record< WorkspacePricingPlans, From f905c8f42872428fe90cfde212316e7da9ee629f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Wed, 23 Oct 2024 08:29:23 +0200 Subject: [PATCH 36/48] fix(gatekeeper): eliminate stripe client import sideeffect 2 --- packages/server/modules/gatekeeper/graph/resolvers/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/server/modules/gatekeeper/graph/resolvers/index.ts b/packages/server/modules/gatekeeper/graph/resolvers/index.ts index 8ac20c25e..36c72fd16 100644 --- a/packages/server/modules/gatekeeper/graph/resolvers/index.ts +++ b/packages/server/modules/gatekeeper/graph/resolvers/index.ts @@ -13,7 +13,7 @@ import { createCheckoutSessionFactory, createCustomerPortalUrlFactory } from '@/modules/gatekeeper/clients/stripe' -import { getWorkspacePlanPrice, stripe } from '@/modules/gatekeeper/stripe' +import { getWorkspacePlanPrice, getStripeClient } from '@/modules/gatekeeper/stripe' import { startCheckoutSessionFactory } from '@/modules/gatekeeper/services/checkout' import { deleteCheckoutSessionFactory, @@ -62,7 +62,7 @@ export = FF_GATEKEEPER_MODULE_ENABLED if (!workspace) throw new Error('This cannot be, if there is a sub, there is a workspace') return await createCustomerPortalUrlFactory({ - stripe, + stripe: getStripeClient(), frontendOrigin: getFrontendOrigin() })({ workspaceId: workspaceSubscription.workspaceId, @@ -99,7 +99,7 @@ export = FF_GATEKEEPER_MODULE_ENABLED ) const createCheckoutSession = createCheckoutSessionFactory({ - stripe, + stripe: getStripeClient(), frontendOrigin: getFrontendOrigin(), getWorkspacePlanPrice }) From 81b923cf678b147400a4b684bd4862e069102316 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Thu, 24 Oct 2024 10:03:08 +0200 Subject: [PATCH 37/48] feat(gatekeeper): upsize subscription on workspace role change --- .../modules/gatekeeper/clients/stripe.ts | 31 +- .../modules/gatekeeper/domain/billing.ts | 35 +- .../gatekeeper/events/eventListener.ts | 40 ++ packages/server/modules/gatekeeper/index.ts | 12 + .../gatekeeper/services/subscriptions.ts | 96 +++- packages/server/modules/gatekeeper/stripe.ts | 9 +- .../tests/unit/subscriptions.spec.ts | 435 ++++++++++++++++-- workspace.code-workspace | 3 +- 8 files changed, 592 insertions(+), 69 deletions(-) create mode 100644 packages/server/modules/gatekeeper/events/eventListener.ts diff --git a/packages/server/modules/gatekeeper/clients/stripe.ts b/packages/server/modules/gatekeeper/clients/stripe.ts index cb61b692f..8d68d64b6 100644 --- a/packages/server/modules/gatekeeper/clients/stripe.ts +++ b/packages/server/modules/gatekeeper/clients/stripe.ts @@ -2,8 +2,8 @@ import { CreateCheckoutSession, GetSubscriptionData, - SubscriptionData, - WorkspaceSubscription + ReconcileSubscriptionData, + SubscriptionData } from '@/modules/gatekeeper/domain/billing' import { WorkspacePlanBillingIntervals, @@ -163,19 +163,13 @@ export const parseSubscriptionData = ( // 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 - }) => { + ({ stripe }: { stripe: Stripe }): ReconcileSubscriptionData => + async ({ subscriptionData, applyProrotation }) => { const existingSubscriptionState = await getSubscriptionDataFactory({ stripe })({ - subscriptionId: workspaceSubscription.subscriptionData.subscriptionId + subscriptionId: subscriptionData.subscriptionId }) const items: Stripe.SubscriptionUpdateParams.Item[] = [] - for (const product of workspaceSubscription.subscriptionData.products) { + for (const product of subscriptionData.products) { const existingProduct = existingSubscriptionState.products.find( (p) => p.productId === product.productId ) @@ -187,13 +181,16 @@ export const reconcileWorkspaceSubscriptionFactory = items.push({ quantity: product.quantity, price: product.priceId }) items.push({ id: product.subscriptionItemId, deleted: true }) } else { - items.push({ quantity: product.quantity, id: product.subscriptionItemId }) + items.push({ + quantity: product.quantity, + id: existingProduct.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' } - ) + await stripe.subscriptions.update(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 index f26c2c39d..3ee6c87e7 100644 --- a/packages/server/modules/gatekeeper/domain/billing.ts +++ b/packages/server/modules/gatekeeper/domain/billing.ts @@ -5,6 +5,7 @@ import { WorkspacePlanBillingIntervals, WorkspacePricingPlans } from '@/modules/gatekeeper/domain/workspacePricing' +import { OverrideProperties } from 'type-fest' import { z } from 'zod' export type UnpaidWorkspacePlanStatuses = 'valid' @@ -109,6 +110,15 @@ export type WorkspaceSubscription = { billingInterval: WorkspacePlanBillingIntervals subscriptionData: SubscriptionData } +const subscriptionProduct = z.object({ + productId: z.string(), + subscriptionItemId: z.string(), + priceId: z.string(), + quantity: z.number() +}) + +type SubscriptionProduct = z.infer + export const subscriptionData = z.object({ subscriptionId: z.string().min(1), customerId: z.string().min(1), @@ -123,15 +133,7 @@ export const subscriptionData = z.object({ z.literal('unpaid'), z.literal('paused') ]), - 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() + products: subscriptionProduct.array() }) // this abstracts the stripe sub data @@ -158,7 +160,18 @@ export type GetWorkspacePlanPrice = (args: { billingInterval: WorkspacePlanBillingIntervals }) => string -export type ReconcileWorkspaceSubscription = (args: { - workspaceSubscription: WorkspaceSubscription +export type GetWorkspacePlanProductId = (args: { + workspacePlan: WorkspacePricingPlans +}) => string + +export type SubscriptionDataInput = OverrideProperties< + SubscriptionData, + { + products: OverrideProperties[] + } +> + +export type ReconcileSubscriptionData = (args: { + subscriptionData: SubscriptionDataInput applyProrotation: boolean }) => Promise diff --git a/packages/server/modules/gatekeeper/events/eventListener.ts b/packages/server/modules/gatekeeper/events/eventListener.ts new file mode 100644 index 000000000..e2ae7fc97 --- /dev/null +++ b/packages/server/modules/gatekeeper/events/eventListener.ts @@ -0,0 +1,40 @@ +import { reconcileWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/clients/stripe' +import { + getWorkspacePlanFactory, + getWorkspaceSubscriptionFactory +} from '@/modules/gatekeeper/repositories/billing' +import { addWorkspaceSubscriptionSeatIfNeededFactory } from '@/modules/gatekeeper/services/subscriptions' +import { + getWorkspacePlanPrice, + getWorkspacePlanProductId +} from '@/modules/gatekeeper/stripe' +import { getEventBus } from '@/modules/shared/services/eventBus' +import { countWorkspaceRoleWithOptionalProjectRoleFactory } from '@/modules/workspaces/repositories/workspaces' +import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events' +import { Knex } from 'knex' +import Stripe from 'stripe' + +export const initializeEventListenersFactory = + ({ db, stripe }: { db: Knex; stripe: Stripe }) => + () => { + const eventBus = getEventBus() + const quitCbs = [ + eventBus.listen(WorkspaceEvents.RoleUpdated, async ({ payload }) => { + const addWorkspaceSubscriptionSeatIfNeeded = + addWorkspaceSubscriptionSeatIfNeededFactory({ + getWorkspacePlan: getWorkspacePlanFactory({ db }), + getWorkspaceSubscription: getWorkspaceSubscriptionFactory({ db }), + countWorkspaceRole: countWorkspaceRoleWithOptionalProjectRoleFactory({ + db + }), + getWorkspacePlanPrice, + getWorkspacePlanProductId, + reconcileSubscriptionData: reconcileWorkspaceSubscriptionFactory({ stripe }) + }) + + await addWorkspaceSubscriptionSeatIfNeeded(payload) + }) + ] + + return () => quitCbs.forEach((quit) => quit()) + } diff --git a/packages/server/modules/gatekeeper/index.ts b/packages/server/modules/gatekeeper/index.ts index ba6cf40c9..7dca178ab 100644 --- a/packages/server/modules/gatekeeper/index.ts +++ b/packages/server/modules/gatekeeper/index.ts @@ -6,6 +6,8 @@ import { getBillingRouter } from '@/modules/gatekeeper/rest/billing' import { registerOrUpdateScopeFactory } from '@/modules/shared/repositories/scopes' import { db } from '@/db/knex' import { gatekeeperScopes } from '@/modules/gatekeeper/scopes' +import { initializeEventListenersFactory } from '@/modules/gatekeeper/events/eventListener' +import { getStripeClient } from '@/modules/gatekeeper/stripe' const { FF_GATEKEEPER_MODULE_ENABLED, FF_BILLING_INTEGRATION_ENABLED } = getFeatureFlags() @@ -15,6 +17,8 @@ const initScopes = async () => { await Promise.all(gatekeeperScopes.map((scope) => registerFunc({ scope }))) } +let quitListeners: (() => void) | undefined = undefined + const gatekeeperModule: SpeckleModule = { async init(app, isInitial) { await initScopes() @@ -35,6 +39,11 @@ const gatekeeperModule: SpeckleModule = { if (FF_BILLING_INTEGRATION_ENABLED) { app.use(getBillingRouter()) + quitListeners = initializeEventListenersFactory({ + db, + stripe: getStripeClient() + })() + const isLicenseValid = await validateModuleLicense({ requiredModules: ['billing'] }) @@ -45,6 +54,9 @@ const gatekeeperModule: SpeckleModule = { // TODO: create a cron job, that removes unused seats from the subscription at the beginning of each workspace plan's billing cycle } } + }, + async shutdown() { + if (quitListeners) quitListeners() } } export = gatekeeperModule diff --git a/packages/server/modules/gatekeeper/services/subscriptions.ts b/packages/server/modules/gatekeeper/services/subscriptions.ts index 1d2553632..dcca63737 100644 --- a/packages/server/modules/gatekeeper/services/subscriptions.ts +++ b/packages/server/modules/gatekeeper/services/subscriptions.ts @@ -1,8 +1,13 @@ import { GetWorkspacePlan, + GetWorkspacePlanPrice, + GetWorkspacePlanProductId, + GetWorkspaceSubscription, GetWorkspaceSubscriptionBySubscriptionId, PaidWorkspacePlanStatuses, + ReconcileSubscriptionData, SubscriptionData, + SubscriptionDataInput, UpsertPaidWorkspacePlan, UpsertWorkspaceSubscription } from '@/modules/gatekeeper/domain/billing' @@ -11,7 +16,9 @@ import { WorkspacePlanNotFoundError, WorkspaceSubscriptionNotFoundError } from '@/modules/gatekeeper/errors/billing' -import { throwUncoveredError } from '@speckle/shared' +import { CountWorkspaceRoleWithOptionalProjectRole } from '@/modules/workspaces/domain/operations' +import { throwUncoveredError, WorkspaceRoles } from '@speckle/shared' +import { cloneDeep, sum } from 'lodash' export const handleSubscriptionUpdateFactory = ({ @@ -74,7 +81,92 @@ export const handleSubscriptionUpdateFactory = }) // if there is a status in the sub, we recognize, we need to update our state await upsertWorkspaceSubscription({ - workspaceSubscription: { ...subscription, subscriptionData } + workspaceSubscription: { + ...subscription, + updatedAt: new Date(), + subscriptionData + } }) } } + +export const addWorkspaceSubscriptionSeatIfNeededFactory = + ({ + getWorkspacePlan, + getWorkspaceSubscription, + countWorkspaceRole, + getWorkspacePlanProductId, + getWorkspacePlanPrice, + reconcileSubscriptionData + }: { + getWorkspacePlan: GetWorkspacePlan + getWorkspaceSubscription: GetWorkspaceSubscription + countWorkspaceRole: CountWorkspaceRoleWithOptionalProjectRole + getWorkspacePlanProductId: GetWorkspacePlanProductId + getWorkspacePlanPrice: GetWorkspacePlanPrice + reconcileSubscriptionData: ReconcileSubscriptionData + }) => + async ({ workspaceId, role }: { workspaceId: string; role: WorkspaceRoles }) => { + const workspacePlan = await getWorkspacePlan({ workspaceId }) + if (!workspacePlan) throw new WorkspacePlanNotFoundError() + const workspaceSubscription = await getWorkspaceSubscription({ workspaceId }) + if (!workspaceSubscription) throw new WorkspaceSubscriptionNotFoundError() + + switch (workspacePlan.name) { + case 'team': + case 'pro': + case 'business': + break + case 'unlimited': + case 'academia': + throw new WorkspacePlanMismatchError() + default: + throwUncoveredError(workspacePlan) + } + + let productId: string + let priceId: string + let roleCount: number + switch (role) { + case 'workspace:guest': + roleCount = await countWorkspaceRole({ workspaceId, workspaceRole: role }) + productId = getWorkspacePlanProductId({ workspacePlan: 'guest' }) + priceId = getWorkspacePlanPrice({ + workspacePlan: 'guest', + billingInterval: workspaceSubscription.billingInterval + }) + break + case 'workspace:admin': + case 'workspace:member': + roleCount = sum( + await Promise.all([ + countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:admin' }), + countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:member' }) + ]) + ) + productId = getWorkspacePlanProductId({ workspacePlan: workspacePlan.name }) + priceId = getWorkspacePlanPrice({ + workspacePlan: workspacePlan.name, + billingInterval: workspaceSubscription.billingInterval + }) + break + default: + throwUncoveredError(role) + } + + const subscriptionData: SubscriptionDataInput = cloneDeep( + workspaceSubscription.subscriptionData + ) + + const currentPlanProduct = subscriptionData.products.find( + (product) => product.productId === productId + ) + if (!currentPlanProduct) { + subscriptionData.products.push({ productId, priceId, quantity: roleCount }) + } else { + // if there is enough seats, we do not have to do anything + if (currentPlanProduct.quantity >= roleCount) return + currentPlanProduct.quantity = roleCount + } + await reconcileSubscriptionData({ subscriptionData, applyProrotation: true }) + } diff --git a/packages/server/modules/gatekeeper/stripe.ts b/packages/server/modules/gatekeeper/stripe.ts index 4fa6cd597..d1ae3bb17 100644 --- a/packages/server/modules/gatekeeper/stripe.ts +++ b/packages/server/modules/gatekeeper/stripe.ts @@ -1,4 +1,7 @@ -import { GetWorkspacePlanPrice } from '@/modules/gatekeeper/domain/billing' +import { + GetWorkspacePlanPrice, + GetWorkspacePlanProductId +} from '@/modules/gatekeeper/domain/billing' import { WorkspacePlanBillingIntervals, WorkspacePricingPlans @@ -43,3 +46,7 @@ export const getWorkspacePlanPrice: GetWorkspacePlanPrice = ({ workspacePlan, billingInterval }) => workspacePlanPrices()[workspacePlan][billingInterval] + +export const getWorkspacePlanProductId: GetWorkspacePlanProductId = ({ + workspacePlan +}) => workspacePlanPrices()[workspacePlan].productId diff --git a/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts b/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts index 1066bfadb..25e082a87 100644 --- a/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts +++ b/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts @@ -1,5 +1,6 @@ import { SubscriptionData, + SubscriptionDataInput, WorkspacePlan, WorkspaceSubscription } from '@/modules/gatekeeper/domain/billing' @@ -8,11 +9,15 @@ import { WorkspacePlanNotFoundError, WorkspaceSubscriptionNotFoundError } from '@/modules/gatekeeper/errors/billing' -import { handleSubscriptionUpdateFactory } from '@/modules/gatekeeper/services/subscriptions' +import { + addWorkspaceSubscriptionSeatIfNeededFactory, + handleSubscriptionUpdateFactory +} from '@/modules/gatekeeper/services/subscriptions' import { expectToThrow } from '@/test/assertionHelper' +import { throwUncoveredError } from '@speckle/shared' import { expect } from 'chai' import cryptoRandomString from 'crypto-random-string' -import { merge } from 'lodash' +import { assign } from 'lodash' const createTestSubscriptionData = ( overrides: Partial = {} @@ -31,7 +36,21 @@ const createTestSubscriptionData = ( status: 'active', subscriptionId: cryptoRandomString({ length: 10 }) } - return merge(defaultValues, overrides) + return assign(defaultValues, overrides) +} + +const createTestWorkspaceSubscription = ( + overrides: Partial = {} +): WorkspaceSubscription => { + const defaultValues: WorkspaceSubscription = { + billingInterval: 'monthly', + createdAt: new Date(), + updatedAt: new Date(), + currentBillingCycleEnd: new Date(), + subscriptionData: createTestSubscriptionData(), + workspaceId: cryptoRandomString({ length: 10 }) + } + return assign(defaultValues, overrides) } describe('subscriptions @gatekeeper', () => { @@ -58,14 +77,8 @@ describe('subscriptions @gatekeeper', () => { const subscriptionData = createTestSubscriptionData() const err = await expectToThrow(async () => { await handleSubscriptionUpdateFactory({ - getWorkspaceSubscriptionBySubscriptionId: async () => ({ - subscriptionData, - billingInterval: 'monthly', - createdAt: new Date(), - updatedAt: new Date(), - currentBillingCycleEnd: new Date(), - workspaceId: cryptoRandomString({ length: 10 }) - }), + getWorkspaceSubscriptionBySubscriptionId: async () => + createTestWorkspaceSubscription({ subscriptionData }), getWorkspacePlan: async () => null, upsertWorkspaceSubscription: async () => { expect.fail() @@ -83,14 +96,11 @@ describe('subscriptions @gatekeeper', () => { const workspaceId = cryptoRandomString({ length: 10 }) const err = await expectToThrow(async () => { await handleSubscriptionUpdateFactory({ - getWorkspaceSubscriptionBySubscriptionId: async () => ({ - subscriptionData, - billingInterval: 'monthly', - createdAt: new Date(), - updatedAt: new Date(), - currentBillingCycleEnd: new Date(), - workspaceId - }), + getWorkspaceSubscriptionBySubscriptionId: async () => + createTestWorkspaceSubscription({ + subscriptionData, + workspaceId + }), getWorkspacePlan: async () => ({ name, workspaceId, status: 'valid' }), upsertWorkspaceSubscription: async () => { expect.fail() @@ -109,14 +119,10 @@ describe('subscriptions @gatekeeper', () => { cancelAt: new Date(2099, 12, 31) }) const workspaceId = cryptoRandomString({ length: 10 }) - const workspaceSubscription = { + const workspaceSubscription = createTestWorkspaceSubscription({ subscriptionData, - billingInterval: 'monthly' as const, - createdAt: new Date(), - updatedAt: new Date(), - currentBillingCycleEnd: new Date(), workspaceId - } + }) let updatedSubscription: WorkspaceSubscription | undefined = undefined let updatedPlan: WorkspacePlan | undefined = undefined @@ -170,14 +176,11 @@ describe('subscriptions @gatekeeper', () => { status: 'past_due' }) const workspaceId = cryptoRandomString({ length: 10 }) - const workspaceSubscription = { + + const workspaceSubscription = createTestWorkspaceSubscription({ subscriptionData, - billingInterval: 'monthly' as const, - createdAt: new Date(), - updatedAt: new Date(), - currentBillingCycleEnd: new Date(), workspaceId - } + }) let updatedSubscription: WorkspaceSubscription | undefined = undefined let updatedPlan: WorkspacePlan | undefined = undefined @@ -233,14 +236,11 @@ describe('subscriptions @gatekeeper', () => { status }) const workspaceId = cryptoRandomString({ length: 10 }) - const workspaceSubscription = { + + const workspaceSubscription = createTestWorkspaceSubscription({ subscriptionData, - billingInterval: 'monthly' as const, - createdAt: new Date(), - updatedAt: new Date(), - currentBillingCycleEnd: new Date(), workspaceId - } + }) await handleSubscriptionUpdateFactory({ getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription, @@ -259,4 +259,365 @@ describe('subscriptions @gatekeeper', () => { }) }) }) + describe('addWorkspaceSubscriptionSeatIfNeededFactory returns a function, that', () => { + it('throws if the workspacePlan is not found', async () => { + const workspaceId = cryptoRandomString({ length: 10 }) + const addWorkspaceSubscriptionSeatIfNeeded = + addWorkspaceSubscriptionSeatIfNeededFactory({ + getWorkspacePlan: async () => null, + getWorkspaceSubscription: async () => { + expect.fail() + }, + countWorkspaceRole: async () => { + expect.fail() + }, + getWorkspacePlanPrice: () => { + expect.fail() + }, + getWorkspacePlanProductId: () => { + expect.fail() + }, + reconcileSubscriptionData: async () => { + expect.fail() + } + }) + const err = await expectToThrow(async () => { + await addWorkspaceSubscriptionSeatIfNeeded({ + workspaceId, + role: 'workspace:admin' + }) + }) + expect(err.message).to.equal(new WorkspacePlanNotFoundError().message) + }) + it('throws if the workspaceSubscription is not found', async () => { + const workspaceId = cryptoRandomString({ length: 10 }) + const addWorkspaceSubscriptionSeatIfNeeded = + addWorkspaceSubscriptionSeatIfNeededFactory({ + getWorkspacePlan: async () => ({ + name: 'unlimited', + workspaceId, + status: 'valid' + }), + getWorkspaceSubscription: async () => null, + countWorkspaceRole: async () => { + expect.fail() + }, + getWorkspacePlanPrice: () => { + expect.fail() + }, + getWorkspacePlanProductId: () => { + expect.fail() + }, + reconcileSubscriptionData: async () => { + expect.fail() + } + }) + const err = await expectToThrow(async () => { + await addWorkspaceSubscriptionSeatIfNeeded({ + workspaceId, + role: 'workspace:admin' + }) + }) + expect(err.message).to.equal(new WorkspaceSubscriptionNotFoundError().message) + }) + it('throws if a non paid plan, has a subscription', async () => { + const workspaceId = cryptoRandomString({ length: 10 }) + const subscriptionData = createTestSubscriptionData({ products: [] }) + const workspaceSubscription = createTestWorkspaceSubscription({ + workspaceId, + subscriptionData + }) + const addWorkspaceSubscriptionSeatIfNeeded = + addWorkspaceSubscriptionSeatIfNeededFactory({ + getWorkspacePlan: async () => ({ + name: 'unlimited', + workspaceId, + status: 'valid' + }), + getWorkspaceSubscription: async () => workspaceSubscription, + countWorkspaceRole: async () => { + expect.fail() + }, + getWorkspacePlanPrice: () => { + expect.fail() + }, + getWorkspacePlanProductId: () => { + expect.fail() + }, + reconcileSubscriptionData: async () => { + expect.fail() + } + }) + const err = await expectToThrow(async () => { + await addWorkspaceSubscriptionSeatIfNeeded({ + workspaceId, + role: 'workspace:admin' + }) + }) + expect(err.message).to.equal(new WorkspacePlanMismatchError().message) + }) + it('uses the guest count, guest product and price id if the new role is workspace:guest', async () => { + const workspaceId = cryptoRandomString({ length: 10 }) + const subscriptionData = createTestSubscriptionData({ products: [] }) + const workspaceSubscription = createTestWorkspaceSubscription({ + workspaceId, + subscriptionData + }) + const workspacePlan: WorkspacePlan = { + name: 'team', + workspaceId, + status: 'valid' + } + const priceId = cryptoRandomString({ length: 10 }) + const productId = cryptoRandomString({ length: 10 }) + const roleCount = 10 + + let reconciledSubscriptionData: SubscriptionDataInput | undefined = undefined + const addWorkspaceSubscriptionSeatIfNeeded = + addWorkspaceSubscriptionSeatIfNeededFactory({ + getWorkspacePlan: async () => workspacePlan, + getWorkspaceSubscription: async () => workspaceSubscription, + countWorkspaceRole: async ({ workspaceRole }) => { + switch (workspaceRole) { + case 'workspace:admin': + case 'workspace:member': + expect.fail() + case 'workspace:guest': + return roleCount + } + }, + getWorkspacePlanPrice: ({ workspacePlan, billingInterval }) => { + if (billingInterval !== workspaceSubscription.billingInterval) expect.fail() + switch (workspacePlan) { + case 'business': + case 'team': + case 'pro': + expect.fail() + case 'guest': + return priceId + default: + throwUncoveredError(workspacePlan) + } + }, + getWorkspacePlanProductId: (args) => { + if (args.workspacePlan !== 'guest') expect.fail() + return productId + }, + reconcileSubscriptionData: async ({ applyProrotation, subscriptionData }) => { + if (!applyProrotation) expect.fail() + reconciledSubscriptionData = subscriptionData + } + }) + await addWorkspaceSubscriptionSeatIfNeeded({ + workspaceId, + role: 'workspace:guest' + }) + expect(reconciledSubscriptionData!.products).deep.equalInAnyOrder([ + { productId, priceId, quantity: roleCount } + ]) + }) + ;(['workspace:member', 'workspace:admin'] as const).forEach((role) => + it(`uses the admin + member count, workspacePlan product and price id if the new role is ${role}`, async () => { + const workspaceId = cryptoRandomString({ length: 10 }) + const subscriptionData = createTestSubscriptionData({ products: [] }) + const workspaceSubscription = createTestWorkspaceSubscription({ + workspaceId, + subscriptionData + }) + const workspacePlan: WorkspacePlan = { + name: 'team', + workspaceId, + status: 'valid' + } + const priceId = cryptoRandomString({ length: 10 }) + const productId = cryptoRandomString({ length: 10 }) + const roleCount = 10 + + let reconciledSubscriptionData: SubscriptionDataInput | undefined = undefined + const addWorkspaceSubscriptionSeatIfNeeded = + addWorkspaceSubscriptionSeatIfNeededFactory({ + getWorkspacePlan: async () => workspacePlan, + getWorkspaceSubscription: async () => workspaceSubscription, + countWorkspaceRole: async ({ workspaceRole }) => { + switch (workspaceRole) { + case 'workspace:admin': + case 'workspace:member': + return roleCount + case 'workspace:guest': + expect.fail() + } + }, + getWorkspacePlanPrice: ({ workspacePlan, billingInterval }) => { + if (billingInterval !== workspaceSubscription.billingInterval) + expect.fail() + switch (workspacePlan) { + case 'business': + case 'pro': + case 'guest': + expect.fail() + case 'team': + return priceId + default: + throwUncoveredError(workspacePlan) + } + }, + getWorkspacePlanProductId: (args) => { + if (args.workspacePlan !== workspacePlan.name) expect.fail() + return productId + }, + reconcileSubscriptionData: async ({ + applyProrotation, + subscriptionData + }) => { + if (!applyProrotation) expect.fail() + reconciledSubscriptionData = subscriptionData + } + }) + await addWorkspaceSubscriptionSeatIfNeeded({ + workspaceId, + role + }) + expect(reconciledSubscriptionData!.products).deep.equalInAnyOrder([ + { productId, priceId, quantity: 2 * roleCount } + ]) + }) + ) + it('updates the sub existing product quantity if the one matching the new role, does not have enough quantities', async () => { + const workspaceId = cryptoRandomString({ length: 10 }) + + const priceId = cryptoRandomString({ length: 10 }) + const productId = cryptoRandomString({ length: 10 }) + const subscriptionItemId = cryptoRandomString({ length: 10 }) + const subscriptionData = createTestSubscriptionData({ + products: [ + { + priceId, + productId, + quantity: 4, + subscriptionItemId + } + ] + }) + const workspaceSubscription = createTestWorkspaceSubscription({ + workspaceId, + subscriptionData + }) + const workspacePlan: WorkspacePlan = { + name: 'team', + workspaceId, + status: 'valid' + } + const roleCount = 10 + + let reconciledSubscriptionData: SubscriptionDataInput | undefined = undefined + const addWorkspaceSubscriptionSeatIfNeeded = + addWorkspaceSubscriptionSeatIfNeededFactory({ + getWorkspacePlan: async () => workspacePlan, + getWorkspaceSubscription: async () => workspaceSubscription, + countWorkspaceRole: async ({ workspaceRole }) => { + switch (workspaceRole) { + case 'workspace:admin': + case 'workspace:member': + return roleCount + case 'workspace:guest': + expect.fail() + } + }, + getWorkspacePlanPrice: ({ workspacePlan, billingInterval }) => { + if (billingInterval !== workspaceSubscription.billingInterval) expect.fail() + switch (workspacePlan) { + case 'business': + case 'pro': + case 'guest': + expect.fail() + case 'team': + return priceId + default: + throwUncoveredError(workspacePlan) + } + }, + getWorkspacePlanProductId: (args) => { + if (args.workspacePlan !== workspacePlan.name) expect.fail() + return productId + }, + reconcileSubscriptionData: async ({ applyProrotation, subscriptionData }) => { + if (!applyProrotation) expect.fail() + reconciledSubscriptionData = subscriptionData + } + }) + await addWorkspaceSubscriptionSeatIfNeeded({ + workspaceId, + role: 'workspace:member' + }) + expect(reconciledSubscriptionData!.products).deep.equalInAnyOrder([ + { productId, priceId, quantity: 2 * roleCount, subscriptionItemId } + ]) + }) + it('does not update the subscription if the product matching the new role, has enough quantities', async () => { + const workspaceId = cryptoRandomString({ length: 10 }) + + const priceId = cryptoRandomString({ length: 10 }) + const productId = cryptoRandomString({ length: 10 }) + const subscriptionItemId = cryptoRandomString({ length: 10 }) + const subscriptionData = createTestSubscriptionData({ + products: [ + { + priceId, + productId, + quantity: 2, + subscriptionItemId + } + ] + }) + const workspaceSubscription = createTestWorkspaceSubscription({ + workspaceId, + subscriptionData + }) + const workspacePlan: WorkspacePlan = { + name: 'team', + workspaceId, + status: 'valid' + } + const roleCount = 1 + + const addWorkspaceSubscriptionSeatIfNeeded = + addWorkspaceSubscriptionSeatIfNeededFactory({ + getWorkspacePlan: async () => workspacePlan, + getWorkspaceSubscription: async () => workspaceSubscription, + countWorkspaceRole: async ({ workspaceRole }) => { + switch (workspaceRole) { + case 'workspace:admin': + case 'workspace:member': + return roleCount + case 'workspace:guest': + expect.fail() + } + }, + getWorkspacePlanPrice: ({ workspacePlan, billingInterval }) => { + if (billingInterval !== workspaceSubscription.billingInterval) expect.fail() + switch (workspacePlan) { + case 'business': + case 'pro': + case 'guest': + expect.fail() + case 'team': + return priceId + default: + throwUncoveredError(workspacePlan) + } + }, + getWorkspacePlanProductId: (args) => { + if (args.workspacePlan !== workspacePlan.name) expect.fail() + return productId + }, + reconcileSubscriptionData: async () => { + expect.fail() + } + }) + await addWorkspaceSubscriptionSeatIfNeeded({ + workspaceId, + role: 'workspace:member' + }) + }) + }) }) diff --git a/workspace.code-workspace b/workspace.code-workspace index 75d24d382..48902a487 100644 --- a/workspace.code-workspace +++ b/workspace.code-workspace @@ -97,7 +97,8 @@ "Encryptor", "Insertable", "mjml", - "OIDC" + "OIDC", + "Prorotation" ], "tailwindCSS.experimental.configFile": { "packages/frontend-2/tailwind.config.mjs": "packages/frontend-2/**" From f6e804dfe7a41d50fd1a1470ea2fe111f4dd6abb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Thu, 24 Oct 2024 19:27:44 +0200 Subject: [PATCH 38/48] feat(shared): add command pattern implementation --- packages/server/modules/shared/command.ts | 29 +++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 packages/server/modules/shared/command.ts diff --git a/packages/server/modules/shared/command.ts b/packages/server/modules/shared/command.ts new file mode 100644 index 000000000..bc87e4b7d --- /dev/null +++ b/packages/server/modules/shared/command.ts @@ -0,0 +1,29 @@ +import { EmitArg, EventBus, EventBusEmit } from '@/modules/shared/services/eventBus' +import { Knex } from 'knex' + +export const commandFactory = + ) => ReturnType>({ + db, + eventBus, + operationFactory + }: { + db: Knex + eventBus: EventBus + operationFactory: (arg: { db: Knex; emit: EventBusEmit }) => TOperation + }) => + async (...args: Parameters): Promise>> => { + const trx = await db.transaction() + + const events: EmitArg[] = [] + const emit: EventBusEmit = async ({ eventName, payload }) => { + events.push({ eventName, payload }) + } + + const result = await operationFactory({ db, emit })(...args) + + await trx.commit() + for (const event of events) { + eventBus.emit(event) + } + return result as Awaited> + } From abb42a3da2168629ce411213023fae78e1966c15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Thu, 24 Oct 2024 19:54:37 +0200 Subject: [PATCH 39/48] refactor(eventBus): remove return capabilities from the event bus --- .../server/modules/shared/services/eventBus.ts | 5 +++-- .../modules/shared/test/unit/eventBus.spec.ts | 16 ---------------- .../modules/workspaces/domain/operations.ts | 2 +- .../tests/unit/services/join.spec.ts | 1 - .../tests/unit/services/management.spec.ts | 18 +++--------------- 5 files changed, 7 insertions(+), 35 deletions(-) diff --git a/packages/server/modules/shared/services/eventBus.ts b/packages/server/modules/shared/services/eventBus.ts index 05e2c8b70..36f839373 100644 --- a/packages/server/modules/shared/services/eventBus.ts +++ b/packages/server/modules/shared/services/eventBus.ts @@ -86,9 +86,9 @@ export function initializeEventBus() { emit: async (args: { eventName: EventName payload: EventTypes[EventName] - }): Promise => { + }): Promise => { // curate the proper payload here and eventName object here, before emitting - return emitter.emitAsync(args.eventName, args) + await emitter.emitAsync(args.eventName, args) }, /** @@ -124,6 +124,7 @@ export function initializeEventBus() { export type EventBus = ReturnType export type EventBusPayloads = EventTypes export type EventBusEmit = EventBus['emit'] +export type EmitArg = Parameters[0] let eventBus: EventBus diff --git a/packages/server/modules/shared/test/unit/eventBus.spec.ts b/packages/server/modules/shared/test/unit/eventBus.spec.ts index 20987245f..6fb246498 100644 --- a/packages/server/modules/shared/test/unit/eventBus.spec.ts +++ b/packages/server/modules/shared/test/unit/eventBus.spec.ts @@ -62,22 +62,6 @@ describe('Event Bus', () => { await testEventBus.emit({ eventName: 'test.string', payload: 'fake event' }) expect(eventNumbers.sort((a, b) => a - b)).to.deep.equal([1, 1, 2]) }) - it('returns results from listeners to the emitter', async () => { - const testEventBus = initializeEventBus() - - testEventBus.listen('test.string', ({ payload }) => ({ - outcome: payload - })) - - const lookWhatHappened = 'echo this back to me' - const results = await testEventBus.emit({ - eventName: 'test.string', - payload: lookWhatHappened - }) - - expect(results.length).to.equal(1) - expect(results[0]).to.deep.equal({ outcome: lookWhatHappened }) - }) it('bubbles up listener exceptions to emitter', async () => { const testEventBus = initializeEventBus() diff --git a/packages/server/modules/workspaces/domain/operations.ts b/packages/server/modules/workspaces/domain/operations.ts index b78eba1ab..c0bb7f49d 100644 --- a/packages/server/modules/workspaces/domain/operations.ts +++ b/packages/server/modules/workspaces/domain/operations.ts @@ -214,7 +214,7 @@ export type UpdateWorkspaceProjectRole = ( export type EmitWorkspaceEvent = (args: { eventName: TEvent payload: EventBusPayloads[TEvent] -}) => Promise +}) => Promise export type CountProjectsVersionsByWorkspaceId = (args: { workspaceId: string diff --git a/packages/server/modules/workspaces/tests/unit/services/join.spec.ts b/packages/server/modules/workspaces/tests/unit/services/join.spec.ts index 2b3fdfdef..70143fbe1 100644 --- a/packages/server/modules/workspaces/tests/unit/services/join.spec.ts +++ b/packages/server/modules/workspaces/tests/unit/services/join.spec.ts @@ -123,7 +123,6 @@ describe('Workspace join services', () => { }, emitWorkspaceEvent: async ({ eventName }) => { firedEvents.push(eventName) - return [] } })({ userId, workspaceId }) diff --git a/packages/server/modules/workspaces/tests/unit/services/management.spec.ts b/packages/server/modules/workspaces/tests/unit/services/management.spec.ts index 74cff4e41..7a4803ed0 100644 --- a/packages/server/modules/workspaces/tests/unit/services/management.spec.ts +++ b/packages/server/modules/workspaces/tests/unit/services/management.spec.ts @@ -79,7 +79,6 @@ const buildCreateWorkspaceWithTestContext = ( context.eventData.isCalled = true context.eventData.eventName = eventName context.eventData.payload = payload - return [] }, ...dependencyOverrides } @@ -408,9 +407,7 @@ describe('Workspace services', () => { let newWorkspaceName await updateWorkspaceFactory({ getWorkspace: async () => workspace, - emitWorkspaceEvent: async () => { - return [] - }, + emitWorkspaceEvent: async () => {}, validateSlug: async () => {}, upsertWorkspace: async ({ workspace }) => { @@ -448,9 +445,7 @@ describe('Workspace services', () => { await updateWorkspaceFactory({ getWorkspace: async () => workspace, - emitWorkspaceEvent: async () => { - return [] - }, + emitWorkspaceEvent: async () => {}, validateSlug: async () => {}, upsertWorkspace: async ({ workspace }) => { updatedWorkspace = workspace @@ -544,8 +539,6 @@ const buildDeleteWorkspaceRoleAndTestContext = ( break } } - - return [] }, ...dependencyOverrides } @@ -622,8 +615,6 @@ const buildUpdateWorkspaceRoleAndTestContext = ( break } } - - return [] }, ...dependencyOverrides } @@ -1205,7 +1196,6 @@ describe('Workspace role services', () => { }, emitWorkspaceEvent: async ({ eventName }) => { omittedEventName = eventName - return [] }, storeWorkspaceDomain: async ({ workspaceDomain }) => { storedDomains = workspaceDomain @@ -1272,9 +1262,7 @@ describe('Workspace role services', () => { upsertWorkspace: async ({ workspace }) => { workspaceData = { ...workspaceData, ...workspace } }, - emitWorkspaceEvent: async () => { - return [] - }, + emitWorkspaceEvent: async () => {}, storeWorkspaceDomain: async ({ workspaceDomain }) => { insertedDomains.push(workspaceDomain) } From e05506b2a246ed4f6976049ca44557cfe4c12575 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Thu, 24 Oct 2024 21:58:01 +0200 Subject: [PATCH 40/48] refactor(workspaces): use new commandFactory in workspace resolver --- .../workspaces/graph/resolvers/workspaces.ts | 123 +++++++++--------- 1 file changed, 61 insertions(+), 62 deletions(-) diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts index ad271e594..40d0004a9 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts @@ -42,7 +42,6 @@ import { import { createProjectInviteFactory } from '@/modules/serverinvites/services/projectInviteManagement' import { getInvitationTargetUsersFactory } from '@/modules/serverinvites/services/retrieval' import { authorizeResolver } from '@/modules/shared' -import { withTransaction } from '@/modules/shared/helpers/dbHelper' import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' import { getEventBus } from '@/modules/shared/services/eventBus' import { WorkspaceInviteResourceType } from '@/modules/workspaces/domain/constants' @@ -149,7 +148,9 @@ import { publish } from '@/modules/shared/utils/subscriptions' import { updateStreamRoleAndNotifyFactory } from '@/modules/core/services/streams/management' import { getUserFactory, getUsersFactory } from '@/modules/core/repositories/users' import { getServerInfoFactory } from '@/modules/core/repositories/server' +import { commandFactory } from '@/modules/shared/command' +const eventBus = getEventBus() const getServerInfo = getServerInfoFactory({ db }) const getUser = getUserFactory({ db }) const getUsers = getUsersFactory({ db }) @@ -456,36 +457,36 @@ export = FF_WORKSPACES_MODULE_ENABLED ) if (!role) { - const trx = await db.transaction() - - const deleteWorkspaceRole = deleteWorkspaceRoleFactory({ - deleteWorkspaceRole: repoDeleteWorkspaceRoleFactory({ db: trx }), - getWorkspaceRoles: getWorkspaceRolesFactory({ db: trx }), - emitWorkspaceEvent: getEventBus().emit + const deleteWorkspaceRole = commandFactory({ + db, + eventBus, + operationFactory: ({ db, emit }) => + deleteWorkspaceRoleFactory({ + deleteWorkspaceRole: repoDeleteWorkspaceRoleFactory({ db }), + getWorkspaceRoles: getWorkspaceRolesFactory({ db }), + emitWorkspaceEvent: emit + }) }) - - await withTransaction(deleteWorkspaceRole(args.input), trx) + deleteWorkspaceRole(args.input) } else { if (!isWorkspaceRole(role)) { throw new WorkspaceInvalidRoleError() } - - const trx = await db.transaction() - - const updateWorkspaceRole = updateWorkspaceRoleFactory({ - upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db: trx }), - getWorkspaceWithDomains: getWorkspaceWithDomainsFactory({ db: trx }), - findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({ - db: trx - }), - getWorkspaceRoles: getWorkspaceRolesFactory({ db: trx }), - emitWorkspaceEvent: getEventBus().emit + const updateWorkspaceRole = commandFactory({ + db, + eventBus, + operationFactory: ({ db, emit }) => + updateWorkspaceRoleFactory({ + upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db }), + getWorkspaceWithDomains: getWorkspaceWithDomainsFactory({ db }), + findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({ + db + }), + getWorkspaceRoles: getWorkspaceRolesFactory({ db }), + emitWorkspaceEvent: emit + }) }) - - await withTransaction( - updateWorkspaceRole({ userId, workspaceId, role }), - trx - ) + await updateWorkspaceRole({ userId, workspaceId, role }) } return await getWorkspaceFactory({ db })({ workspaceId }) @@ -559,19 +560,17 @@ export = FF_WORKSPACES_MODULE_ENABLED }) }, leave: async (_parent, args, context) => { - const trx = await db.transaction() - - const deleteWorkspaceRole = deleteWorkspaceRoleFactory({ - deleteWorkspaceRole: repoDeleteWorkspaceRoleFactory({ db: trx }), - getWorkspaceRoles: getWorkspaceRolesFactory({ db: trx }), - emitWorkspaceEvent: getEventBus().emit + const deleteWorkspaceRole = commandFactory({ + db, + eventBus, + operationFactory: ({ db, emit }) => + deleteWorkspaceRoleFactory({ + deleteWorkspaceRole: repoDeleteWorkspaceRoleFactory({ db }), + getWorkspaceRoles: getWorkspaceRolesFactory({ db }), + emitWorkspaceEvent: emit + }) }) - - await withTransaction( - deleteWorkspaceRole({ workspaceId: args.id, userId: context.userId! }), - trx - ) - + deleteWorkspaceRole({ workspaceId: args.id, userId: context.userId! }) return true }, invites: () => ({}), @@ -770,33 +769,33 @@ export = FF_WORKSPACES_MODULE_ENABLED context.resourceAccessRules ) - const trx = await db.transaction() - - const moveProjectToWorkspace = moveProjectToWorkspaceFactory({ - getProject: getProjectFactory({ db }), - updateProject: updateProjectFactory({ db: trx }), - upsertProjectRole: upsertProjectRoleFactory({ db: trx }), - getProjectCollaborators: getProjectCollaboratorsFactory({ db }), - getWorkspaceRoles: getWorkspaceRolesFactory({ db: trx }), - getWorkspaceRoleToDefaultProjectRoleMapping: - getWorkspaceRoleToDefaultProjectRoleMappingFactory({ - getWorkspace: getWorkspaceFactory({ db }) - }), - updateWorkspaceRole: updateWorkspaceRoleFactory({ - getWorkspaceRoles: getWorkspaceRolesFactory({ db: trx }), - getWorkspaceWithDomains: getWorkspaceWithDomainsFactory({ db: trx }), - findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({ - db: trx - }), - upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db: trx }), - emitWorkspaceEvent: getEventBus().emit - }) + const moveProjectToWorkspace = commandFactory({ + db, + eventBus, + operationFactory: ({ db, emit }) => + moveProjectToWorkspaceFactory({ + getProject: getProjectFactory({ db }), + updateProject: updateProjectFactory({ db }), + upsertProjectRole: upsertProjectRoleFactory({ db }), + getProjectCollaborators: getProjectCollaboratorsFactory({ db }), + getWorkspaceRoles: getWorkspaceRolesFactory({ db }), + getWorkspaceRoleToDefaultProjectRoleMapping: + getWorkspaceRoleToDefaultProjectRoleMappingFactory({ + getWorkspace: getWorkspaceFactory({ db }) + }), + updateWorkspaceRole: updateWorkspaceRoleFactory({ + getWorkspaceRoles: getWorkspaceRolesFactory({ db }), + getWorkspaceWithDomains: getWorkspaceWithDomainsFactory({ db }), + findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({ + db + }), + upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db }), + emitWorkspaceEvent: emit + }) + }) }) - return await withTransaction( - moveProjectToWorkspace({ projectId, workspaceId }), - trx - ) + return await moveProjectToWorkspace({ projectId, workspaceId }) } }, Workspace: { From 9049d73489fd0133a753b8f3aa03abdbf09be740 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Thu, 24 Oct 2024 22:54:08 +0200 Subject: [PATCH 41/48] feat(core): facelift taskLock --- .../server/modules/activitystream/index.ts | 8 ++- .../core/domain/scheduledTasks/operations.ts | 2 + .../core/domain/scheduledTasks/types.ts | 7 ++- packages/server/modules/core/helpers/types.ts | 5 -- .../core/repositories/scheduledTasks.ts | 23 ++++--- .../modules/core/services/taskScheduler.ts | 34 ++++++---- .../tests/integration/scheduledTasks.spec.ts | 62 ++++++++++++++++++ .../tests/{ => unit}/scheduledTasks.spec.ts | 63 ++++++------------- packages/server/modules/webhooks/index.ts | 8 ++- 9 files changed, 136 insertions(+), 76 deletions(-) create mode 100644 packages/server/modules/core/tests/integration/scheduledTasks.spec.ts rename packages/server/modules/core/tests/{ => unit}/scheduledTasks.spec.ts (59%) diff --git a/packages/server/modules/activitystream/index.ts b/packages/server/modules/activitystream/index.ts index cb8a38d19..f893c6b2b 100644 --- a/packages/server/modules/activitystream/index.ts +++ b/packages/server/modules/activitystream/index.ts @@ -20,7 +20,10 @@ import { } from '@/modules/activitystream/services/accessRequestActivity' import { ScheduleExecution } from '@/modules/core/domain/scheduledTasks/operations' import { scheduleExecutionFactory } from '@/modules/core/services/taskScheduler' -import { acquireTaskLockFactory } from '@/modules/core/repositories/scheduledTasks' +import { + acquireTaskLockFactory, + releaseTaskLockFactory +} from '@/modules/core/repositories/scheduledTasks' let scheduledTask: ReturnType | null = null let quitEventListeners: Optional> = @@ -44,7 +47,8 @@ const initializeEventListeners = () => { const scheduleWeeklyActivityNotifications = () => { const scheduleExecution = scheduleExecutionFactory({ - acquireTaskLock: acquireTaskLockFactory({ db }) + acquireTaskLock: acquireTaskLockFactory({ db }), + releaseTaskLock: releaseTaskLockFactory({ db }) }) // just to test stuff diff --git a/packages/server/modules/core/domain/scheduledTasks/operations.ts b/packages/server/modules/core/domain/scheduledTasks/operations.ts index e1f51a858..906f51357 100644 --- a/packages/server/modules/core/domain/scheduledTasks/operations.ts +++ b/packages/server/modules/core/domain/scheduledTasks/operations.ts @@ -5,6 +5,8 @@ export type AcquireTaskLock = ( scheduledTask: ScheduledTask ) => Promise +export type ReleaseTaskLock = (args: { taskName: string }) => Promise + export type ScheduleExecution = ( cronExpression: string, taskName: string, diff --git a/packages/server/modules/core/domain/scheduledTasks/types.ts b/packages/server/modules/core/domain/scheduledTasks/types.ts index e2b6d11ad..4e5f62379 100644 --- a/packages/server/modules/core/domain/scheduledTasks/types.ts +++ b/packages/server/modules/core/domain/scheduledTasks/types.ts @@ -1,3 +1,4 @@ -import { ScheduledTaskRecord } from '@/modules/core/helpers/types' - -export type ScheduledTask = ScheduledTaskRecord +export type ScheduledTask = { + taskName: string + lockExpiresAt: Date +} diff --git a/packages/server/modules/core/helpers/types.ts b/packages/server/modules/core/helpers/types.ts index b5eac097f..0178da52b 100644 --- a/packages/server/modules/core/helpers/types.ts +++ b/packages/server/modules/core/helpers/types.ts @@ -125,11 +125,6 @@ export type BranchRecord = { updatedAt: Date } -export type ScheduledTaskRecord = { - taskName: string - lockExpiresAt: Date -} - export type ObjectRecord = { id: string speckleType: string diff --git a/packages/server/modules/core/repositories/scheduledTasks.ts b/packages/server/modules/core/repositories/scheduledTasks.ts index eb7f2ee96..56fe64cdf 100644 --- a/packages/server/modules/core/repositories/scheduledTasks.ts +++ b/packages/server/modules/core/repositories/scheduledTasks.ts @@ -1,22 +1,31 @@ import { ScheduledTasks } from '@/modules/core/dbSchema' -import { AcquireTaskLock } from '@/modules/core/domain/scheduledTasks/operations' -import { ScheduledTaskRecord } from '@/modules/core/helpers/types' +import { + AcquireTaskLock, + ReleaseTaskLock +} from '@/modules/core/domain/scheduledTasks/operations' +import { ScheduledTask } from '@/modules/core/domain/scheduledTasks/types' import { Knex } from 'knex' const tables = { - scheduledTasks: (db: Knex) => db(ScheduledTasks.name) + scheduledTasks: (db: Knex) => db(ScheduledTasks.name) } export const acquireTaskLockFactory = - (deps: { db: Knex }): AcquireTaskLock => - async (scheduledTask: ScheduledTaskRecord): Promise => { + ({ db }: { db: Knex }): AcquireTaskLock => + async (scheduledTask) => { const now = new Date() const [lock] = await tables - .scheduledTasks(deps.db) + .scheduledTasks(db) .insert(scheduledTask) .onConflict(ScheduledTasks.withoutTablePrefix.col.taskName) .merge() .where(ScheduledTasks.col.lockExpiresAt, '<', now) .returning('*') - return (lock as ScheduledTaskRecord) ?? null + return lock ?? null + } + +export const releaseTaskLockFactory = + ({ db }: { db: Knex }): ReleaseTaskLock => + async ({ taskName }) => { + await tables.scheduledTasks(db).where({ taskName }).delete() } diff --git a/packages/server/modules/core/services/taskScheduler.ts b/packages/server/modules/core/services/taskScheduler.ts index 3b0a7163b..c627302dd 100644 --- a/packages/server/modules/core/services/taskScheduler.ts +++ b/packages/server/modules/core/services/taskScheduler.ts @@ -4,6 +4,7 @@ import { ensureError } from '@/modules/shared/helpers/errorHelper' import { activitiesLogger } from '@/logging/logging' import { AcquireTaskLock, + ReleaseTaskLock, ScheduleExecution } from '@/modules/core/domain/scheduledTasks/operations' @@ -12,22 +13,20 @@ export const scheduledCallbackWrapper = async ( taskName: string, lockTimeout: number, callback: (scheduledTime: Date) => Promise, - acquireLock: AcquireTaskLock + acquireLock: AcquireTaskLock, + releaseTaskLock: ReleaseTaskLock ) => { const boundLogger = activitiesLogger.child({ taskName }) // try to acquire the task lock with the function name and a new expiration date const lockExpiresAt = new Date(scheduledTime.getTime() + lockTimeout) + const lock = await acquireLock({ taskName, lockExpiresAt }) + + // if couldn't acquire it, stop execution + if (!lock) { + boundLogger.warn(`Could not acquire task lock for ${taskName}, stopping execution.`) + return + } try { - const lock = await acquireLock({ taskName, lockExpiresAt }) - - // if couldn't acquire it, stop execution - if (!lock) { - boundLogger.warn( - `Could not acquire task lock for ${taskName}, stopping execution.` - ) - return null - } - // else continue executing the callback... boundLogger.info(`Executing scheduled function ${taskName} at ${scheduledTime}`) await callback(scheduledTime) @@ -45,11 +44,19 @@ export const scheduledCallbackWrapper = async ( ensureError(error, 'unknown reason').message }` ) + } finally { + releaseTaskLock(lock) } } export const scheduleExecutionFactory = - (deps: { acquireTaskLock: AcquireTaskLock }): ScheduleExecution => + ({ + acquireTaskLock, + releaseTaskLock + }: { + acquireTaskLock: AcquireTaskLock + releaseTaskLock: ReleaseTaskLock + }): ScheduleExecution => ( cronExpression: string, taskName: string, @@ -67,7 +74,8 @@ export const scheduleExecutionFactory = taskName, lockTimeout, callback, - deps.acquireTaskLock + acquireTaskLock, + releaseTaskLock ) }) } diff --git a/packages/server/modules/core/tests/integration/scheduledTasks.spec.ts b/packages/server/modules/core/tests/integration/scheduledTasks.spec.ts new file mode 100644 index 000000000..2a54cbdd0 --- /dev/null +++ b/packages/server/modules/core/tests/integration/scheduledTasks.spec.ts @@ -0,0 +1,62 @@ +import { db } from '@/db/knex' +import { + acquireTaskLockFactory, + releaseTaskLockFactory +} from '@/modules/core/repositories/scheduledTasks' +import { expect } from 'chai' +import cryptoRandomString from 'crypto-random-string' + +describe('scheduledTasks repositories @core', () => { + describe('acquireTaskLockFactory creates a function, that', () => { + it('returns the inserted task lock', async () => { + const taskLock = { + taskName: cryptoRandomString({ length: 10 }), + lockExpiresAt: new Date() + } + const storedTaskLock = await acquireTaskLockFactory({ db })(taskLock) + expect(storedTaskLock).deep.equal(taskLock) + }) + it('acquires lock if the previous lock for the taskName has expired', async () => { + const taskLock = { + taskName: cryptoRandomString({ length: 10 }), + lockExpiresAt: new Date() + } + let storedTaskLock = await acquireTaskLockFactory({ db })(taskLock) + expect(storedTaskLock).deep.equal(taskLock) + taskLock.lockExpiresAt = new Date(2099, 12, 31) + + storedTaskLock = await acquireTaskLockFactory({ db })(taskLock) + expect(storedTaskLock).deep.equal(taskLock) + }) + it('returns null if the previous lock for the task name has not expired', async () => { + const taskLock = { + taskName: cryptoRandomString({ length: 10 }), + lockExpiresAt: new Date(2099, 12, 31) + } + let storedTaskLock = await acquireTaskLockFactory({ db })(taskLock) + expect(storedTaskLock).deep.equal(taskLock) + taskLock.lockExpiresAt = new Date(2199, 12, 31) + + storedTaskLock = await acquireTaskLockFactory({ db })(taskLock) + expect(storedTaskLock).to.be.null + }) + }) + describe('releaseTaskLockFactory creates a function, that', () => { + it('releases a lock by name', async () => { + const taskLock = { + taskName: cryptoRandomString({ length: 10 }), + lockExpiresAt: new Date(2099, 12, 31) + } + let storedTaskLock = await acquireTaskLockFactory({ db })(taskLock) + expect(storedTaskLock).deep.equal(taskLock) + taskLock.lockExpiresAt = new Date(2199, 12, 31) + + storedTaskLock = await acquireTaskLockFactory({ db })(taskLock) + expect(storedTaskLock).to.be.null + await releaseTaskLockFactory({ db })(taskLock) + + storedTaskLock = await acquireTaskLockFactory({ db })(taskLock) + expect(storedTaskLock).deep.equal(taskLock) + }) + }) +}) diff --git a/packages/server/modules/core/tests/scheduledTasks.spec.ts b/packages/server/modules/core/tests/unit/scheduledTasks.spec.ts similarity index 59% rename from packages/server/modules/core/tests/scheduledTasks.spec.ts rename to packages/server/modules/core/tests/unit/scheduledTasks.spec.ts index 3aa95fc28..7cc038f51 100644 --- a/packages/server/modules/core/tests/scheduledTasks.spec.ts +++ b/packages/server/modules/core/tests/unit/scheduledTasks.spec.ts @@ -1,61 +1,26 @@ import { describe } from 'mocha' -import { ScheduledTasks } from '@/modules/core/dbSchema' -import { truncateTables } from '@/test/hooks' import { ensureError } from '@/modules/shared/helpers/errorHelper' import { scheduledCallbackWrapper, scheduleExecutionFactory } from '@/modules/core/services/taskScheduler' import { expect } from 'chai' -import { sleep } from '@/test/helpers' import cryptoRandomString from 'crypto-random-string' -import { acquireTaskLockFactory } from '@/modules/core/repositories/scheduledTasks' -import { db } from '@/db/knex' - -const acquireTaskLock = acquireTaskLockFactory({ db }) -const scheduleExecution = scheduleExecutionFactory({ acquireTaskLock }) describe('Scheduled tasks @core', () => { - describe('Task lock repository', () => { - before(async () => { - await truncateTables([ScheduledTasks.name]) - }) - it('can acquire task lock for a new function name', async () => { - const taskName = cryptoRandomString({ length: 10 }) - const scheduledTask = { taskName, lockExpiresAt: new Date() } - const lock = await acquireTaskLock(scheduledTask) - expect(lock).to.be.deep.equal(scheduledTask) - }) - it('can acquire task lock if previous lock has expired', async () => { - const taskName = cryptoRandomString({ length: 10 }) - const oldTask = { taskName, lockExpiresAt: new Date() } - await acquireTaskLock(oldTask) - - await sleep(100) - const newTask = { taskName, lockExpiresAt: new Date() } - const lock = await acquireTaskLock(newTask) - expect(lock).to.be.deep.equal(newTask) - }) - it('returns an invalid lock (null), if there is another lock in place', async () => { - const taskName = cryptoRandomString({ length: 10 }) - const oldTask = { - taskName, - lockExpiresAt: new Date('2366-12-28 00:30:57.000+00') - } - await acquireTaskLock(oldTask) - const newTask = { taskName, lockExpiresAt: new Date() } - const lock = await acquireTaskLock(newTask) - expect(lock).to.be.null - }) - }) describe('Task scheduler', () => { describe('scheduled callback wrapper function', () => { let callbackExecuted = false + let lockReleased = false async function fakeCallback() { callbackExecuted = true } + async function releaseTaskLock() { + lockReleased = true + } beforeEach(() => { callbackExecuted = false + lockReleased = false }) it("doesn't invoke the callback if it aquires an invalid lock", async () => { expect(callbackExecuted).to.be.false @@ -66,9 +31,11 @@ describe('Scheduled tasks @core', () => { 100, fakeCallback, // fake lock aquire, always returning an invalid lock - async () => null + async () => null, + releaseTaskLock ) expect(callbackExecuted).to.be.false + expect(lockReleased).to.be.false }) it('invokes the callback if a task lock is acquired', async () => { expect(callbackExecuted).to.be.false @@ -79,9 +46,11 @@ describe('Scheduled tasks @core', () => { 100, fakeCallback, // fake lock aquire, always returning an invalid lock - async () => ({ taskName, lockExpiresAt: new Date() }) + async () => ({ taskName, lockExpiresAt: new Date() }), + releaseTaskLock ) expect(callbackExecuted).to.be.true + expect(lockReleased).to.be.true }) it('handles all callback errors gracefully', async () => { expect(callbackExecuted).to.be.false @@ -95,13 +64,19 @@ describe('Scheduled tasks @core', () => { throw 'catch this' }, // fake lock aquire, always returning an invalid lock - async () => ({ taskName, lockExpiresAt: new Date() }) + async () => ({ taskName, lockExpiresAt: new Date() }), + releaseTaskLock ) expect(callbackExecuted).to.be.true + expect(lockReleased).to.be.true }) }) describe('schedule execution', () => { - it('throws an InvalidArgimentError if the cron expression is not valid', async () => { + const scheduleExecution = scheduleExecutionFactory({ + acquireTaskLock: async () => null, + releaseTaskLock: async () => {} + }) + it('throws an InvalidArgumentError if the cron expression is not valid', async () => { const cronExpression = 'this is a borked cron expression' try { scheduleExecution(cronExpression, 'tick tick boom', async () => { diff --git a/packages/server/modules/webhooks/index.ts b/packages/server/modules/webhooks/index.ts index ab9f214f6..48e438303 100644 --- a/packages/server/modules/webhooks/index.ts +++ b/packages/server/modules/webhooks/index.ts @@ -3,12 +3,16 @@ import { SpeckleModule } from '@/modules/shared/helpers/typeHelper' import { cleanOrphanedWebhookConfigs } from '@/modules/webhooks/services/cleanup' import { activitiesLogger, moduleLogger } from '@/logging/logging' import { scheduleExecutionFactory } from '@/modules/core/services/taskScheduler' -import { acquireTaskLockFactory } from '@/modules/core/repositories/scheduledTasks' +import { + acquireTaskLockFactory, + releaseTaskLockFactory +} from '@/modules/core/repositories/scheduledTasks' import { db } from '@/db/knex' const scheduleWebhookCleanup = () => { const scheduleExecution = scheduleExecutionFactory({ - acquireTaskLock: acquireTaskLockFactory({ db }) + acquireTaskLock: acquireTaskLockFactory({ db }), + releaseTaskLock: releaseTaskLockFactory({ db }) }) const cronExpression = '0 4 * * 1' From d6dad6609a91d57868331fc17afc2ca3e16fc583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Mon, 28 Oct 2024 18:58:13 +0100 Subject: [PATCH 42/48] feat(gatekeeper): shedule subscription downscale --- .../gatekeeper/typedefs/gatekeeper.graphql | 2 + .../modules/gatekeeper/domain/billing.ts | 4 +- packages/server/modules/gatekeeper/index.ts | 28 ++++++ .../gatekeeper/services/subscriptions.ts | 90 ++++++++++++++++++- 4 files changed, 122 insertions(+), 2 deletions(-) diff --git a/packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql b/packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql index a1aad81ab..e3f7e884c 100644 --- a/packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql +++ b/packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql @@ -59,8 +59,10 @@ enum WorkspacePlans { enum WorkspacePlanStatuses { valid paymentFailed + cancelationScheduled canceled trial + expired } type WorkspacePlan { diff --git a/packages/server/modules/gatekeeper/domain/billing.ts b/packages/server/modules/gatekeeper/domain/billing.ts index 3ee6c87e7..3414c9f6d 100644 --- a/packages/server/modules/gatekeeper/domain/billing.ts +++ b/packages/server/modules/gatekeeper/domain/billing.ts @@ -117,7 +117,7 @@ const subscriptionProduct = z.object({ quantity: z.number() }) -type SubscriptionProduct = z.infer +export type SubscriptionProduct = z.infer export const subscriptionData = z.object({ subscriptionId: z.string().min(1), @@ -147,6 +147,8 @@ export type GetWorkspaceSubscription = (args: { workspaceId: string }) => Promise +export type GetWorkspaceSubscriptions = () => Promise + export type GetWorkspaceSubscriptionBySubscriptionId = (args: { subscriptionId: string }) => Promise diff --git a/packages/server/modules/gatekeeper/index.ts b/packages/server/modules/gatekeeper/index.ts index 7dca178ab..0cfe0efed 100644 --- a/packages/server/modules/gatekeeper/index.ts +++ b/packages/server/modules/gatekeeper/index.ts @@ -1,3 +1,4 @@ +import cron from 'node-cron' import { moduleLogger } from '@/logging/logging' import { SpeckleModule } from '@/modules/shared/helpers/typeHelper' import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' @@ -8,6 +9,11 @@ import { db } from '@/db/knex' import { gatekeeperScopes } from '@/modules/gatekeeper/scopes' import { initializeEventListenersFactory } from '@/modules/gatekeeper/events/eventListener' import { getStripeClient } from '@/modules/gatekeeper/stripe' +import { scheduleExecutionFactory } from '@/modules/core/services/taskScheduler' +import { + acquireTaskLockFactory, + releaseTaskLockFactory +} from '@/modules/core/repositories/scheduledTasks' const { FF_GATEKEEPER_MODULE_ENABLED, FF_BILLING_INTEGRATION_ENABLED } = getFeatureFlags() @@ -17,6 +23,25 @@ const initScopes = async () => { await Promise.all(gatekeeperScopes.map((scope) => registerFunc({ scope }))) } +const scheduleWorkspaceSubscriptionDownscale = () => { + const scheduleExecution = scheduleExecutionFactory({ + acquireTaskLock: acquireTaskLockFactory({ db }), + releaseTaskLock: releaseTaskLockFactory({ db }) + }) + + const cronExpression = '*/10 * * * * *' + return scheduleExecution( + cronExpression, + 'WorkspaceSubscriptionDownscale', + async () => { + moduleLogger.info('Starting workspace subscription downscale scan') + // await cleanOrphanedWebhookConfigs() + moduleLogger.info('Finished cleanup') + } + ) +} + +let scheduledTask: cron.ScheduledTask | undefined = undefined let quitListeners: (() => void) | undefined = undefined const gatekeeperModule: SpeckleModule = { @@ -39,6 +64,8 @@ const gatekeeperModule: SpeckleModule = { if (FF_BILLING_INTEGRATION_ENABLED) { app.use(getBillingRouter()) + scheduledTask = scheduleWorkspaceSubscriptionDownscale() + quitListeners = initializeEventListenersFactory({ db, stripe: getStripeClient() @@ -57,6 +84,7 @@ const gatekeeperModule: SpeckleModule = { }, async shutdown() { if (quitListeners) quitListeners() + if (scheduledTask) scheduledTask.stop() } } export = gatekeeperModule diff --git a/packages/server/modules/gatekeeper/services/subscriptions.ts b/packages/server/modules/gatekeeper/services/subscriptions.ts index dcca63737..c22d3db4b 100644 --- a/packages/server/modules/gatekeeper/services/subscriptions.ts +++ b/packages/server/modules/gatekeeper/services/subscriptions.ts @@ -4,6 +4,7 @@ import { GetWorkspacePlanProductId, GetWorkspaceSubscription, GetWorkspaceSubscriptionBySubscriptionId, + GetWorkspaceSubscriptions, PaidWorkspacePlanStatuses, ReconcileSubscriptionData, SubscriptionData, @@ -11,6 +12,7 @@ import { UpsertPaidWorkspacePlan, UpsertWorkspaceSubscription } from '@/modules/gatekeeper/domain/billing' +import { WorkspacePricingPlans } from '@/modules/gatekeeper/domain/workspacePricing' import { WorkspacePlanMismatchError, WorkspacePlanNotFoundError, @@ -18,7 +20,7 @@ import { } from '@/modules/gatekeeper/errors/billing' import { CountWorkspaceRoleWithOptionalProjectRole } from '@/modules/workspaces/domain/operations' import { throwUncoveredError, WorkspaceRoles } from '@speckle/shared' -import { cloneDeep, sum } from 'lodash' +import { cloneDeep, isEqual, sum } from 'lodash' export const handleSubscriptionUpdateFactory = ({ @@ -170,3 +172,89 @@ export const addWorkspaceSubscriptionSeatIfNeededFactory = } await reconcileSubscriptionData({ subscriptionData, applyProrotation: true }) } + +const mutateSubscriptionDataWithNewValidSeatNumbers = ({ + seatCount, + workspacePlan, + getWorkspacePlanProductId, + subscriptionData +}: { + seatCount: number + workspacePlan: WorkspacePricingPlans + getWorkspacePlanProductId: GetWorkspacePlanProductId + subscriptionData: SubscriptionData +}): void => { + const productId = getWorkspacePlanProductId({ workspacePlan }) + const product = subscriptionData.products.find( + (product) => product.productId === productId + ) + if (seatCount < 0) throw new Error('Invalid seat count, cannot be negative') + + if (seatCount === 0 && product === undefined) return + if (product !== undefined && product.quantity >= seatCount) { + product.quantity = seatCount + } else { + throw new Error('Invalid subscription state') + } +} + +export const downscaleWorkspaceSubscriptionsFactory = + ({ + getWorkspaceSubscriptions, + getWorkspacePlan, + countWorkspaceRole, + getWorkspacePlanProductId, + reconcileSubscriptionData + }: { + getWorkspaceSubscriptions: GetWorkspaceSubscriptions + getWorkspacePlan: GetWorkspacePlan + countWorkspaceRole: CountWorkspaceRoleWithOptionalProjectRole + getWorkspacePlanProductId: GetWorkspacePlanProductId + reconcileSubscriptionData: ReconcileSubscriptionData + }) => + async () => { + const workspaceSubscriptions = await getWorkspaceSubscriptions() + for (const workspaceSubscription of workspaceSubscriptions) { + const workspaceId = workspaceSubscription.workspaceId + workspaceSubscription.subscriptionData + + const workspacePlan = await getWorkspacePlan({ workspaceId }) + if (!workspacePlan) throw new WorkspacePlanNotFoundError() + + switch (workspacePlan.name) { + case 'team': + case 'pro': + case 'business': + break + case 'unlimited': + case 'academia': + throw new WorkspacePlanMismatchError() + default: + throwUncoveredError(workspacePlan) + } + + const [guestCount, memberCount, adminCount] = await Promise.all([ + countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:guest' }), + countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:member' }), + countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:admin' }) + ]) + + const subscriptionData = cloneDeep(workspaceSubscription.subscriptionData) + + mutateSubscriptionDataWithNewValidSeatNumbers({ + seatCount: guestCount, + workspacePlan: 'guest', + getWorkspacePlanProductId, + subscriptionData + }) + mutateSubscriptionDataWithNewValidSeatNumbers({ + seatCount: memberCount + adminCount, + workspacePlan: workspacePlan.name, + getWorkspacePlanProductId, + subscriptionData + }) + + if (!isEqual(subscriptionData, workspaceSubscription.subscriptionData)) + await reconcileSubscriptionData({ subscriptionData, applyProrotation: false }) + } + } From c0808e55771a98542bed8fa1bb1b56520e69a0be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Tue, 29 Oct 2024 16:46:15 +0100 Subject: [PATCH 43/48] feat(gatekeeper): manage subscription downscale --- .../gatekeeper/services/subscriptions.ts | 154 +++++++++---- .../tests/unit/subscriptions.spec.ts | 202 +++++++++++++++++- 2 files changed, 310 insertions(+), 46 deletions(-) diff --git a/packages/server/modules/gatekeeper/services/subscriptions.ts b/packages/server/modules/gatekeeper/services/subscriptions.ts index c22d3db4b..67dc2cc66 100644 --- a/packages/server/modules/gatekeeper/services/subscriptions.ts +++ b/packages/server/modules/gatekeeper/services/subscriptions.ts @@ -1,3 +1,4 @@ +import { Logger } from '@/logging/logging' import { GetWorkspacePlan, GetWorkspacePlanPrice, @@ -10,7 +11,8 @@ import { SubscriptionData, SubscriptionDataInput, UpsertPaidWorkspacePlan, - UpsertWorkspaceSubscription + UpsertWorkspaceSubscription, + WorkspaceSubscription } from '@/modules/gatekeeper/domain/billing' import { WorkspacePricingPlans } from '@/modules/gatekeeper/domain/workspacePricing' import { @@ -198,63 +200,125 @@ const mutateSubscriptionDataWithNewValidSeatNumbers = ({ } } -export const downscaleWorkspaceSubscriptionsFactory = +const calculateNewBillingCycleEnd = ({ + workspaceSubscription +}: { + workspaceSubscription: WorkspaceSubscription +}): Date => { + const newBillingCycleEnd = new Date(workspaceSubscription.currentBillingCycleEnd) + switch (workspaceSubscription.billingInterval) { + case 'monthly': + newBillingCycleEnd.setMonth(newBillingCycleEnd.getMonth() + 1) + break + case 'yearly': + newBillingCycleEnd.setFullYear(newBillingCycleEnd.getFullYear() + 1) + break + default: + throwUncoveredError(workspaceSubscription.billingInterval) + } + return newBillingCycleEnd +} + +type DownscaleWorkspaceSubscription = (args: { + workspaceSubscription: WorkspaceSubscription +}) => Promise + +export const downscaleWorkspaceSubscriptionFactory = ({ - getWorkspaceSubscriptions, getWorkspacePlan, countWorkspaceRole, getWorkspacePlanProductId, reconcileSubscriptionData }: { - getWorkspaceSubscriptions: GetWorkspaceSubscriptions getWorkspacePlan: GetWorkspacePlan countWorkspaceRole: CountWorkspaceRoleWithOptionalProjectRole getWorkspacePlanProductId: GetWorkspacePlanProductId reconcileSubscriptionData: ReconcileSubscriptionData + }): DownscaleWorkspaceSubscription => + async ({ workspaceSubscription }) => { + const workspaceId = workspaceSubscription.workspaceId + + const workspacePlan = await getWorkspacePlan({ workspaceId }) + if (!workspacePlan) throw new WorkspacePlanNotFoundError() + + switch (workspacePlan.name) { + case 'team': + case 'pro': + case 'business': + break + case 'unlimited': + case 'academia': + throw new WorkspacePlanMismatchError() + default: + throwUncoveredError(workspacePlan) + } + + const [guestCount, memberCount, adminCount] = await Promise.all([ + countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:guest' }), + countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:member' }), + countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:admin' }) + ]) + + const subscriptionData = cloneDeep(workspaceSubscription.subscriptionData) + + mutateSubscriptionDataWithNewValidSeatNumbers({ + seatCount: guestCount, + workspacePlan: 'guest', + getWorkspacePlanProductId, + subscriptionData + }) + mutateSubscriptionDataWithNewValidSeatNumbers({ + seatCount: memberCount + adminCount, + workspacePlan: workspacePlan.name, + getWorkspacePlanProductId, + subscriptionData + }) + + if (!isEqual(subscriptionData, workspaceSubscription.subscriptionData)) { + await reconcileSubscriptionData({ subscriptionData, applyProrotation: false }) + return true + } + return false + } + +export const manageSubscriptionDownscaleFactory = + ({ + logger, + getWorkspaceSubscriptions, + downscaleWorkspaceSubscription, + updateWorkspaceSubscription + }: { + getWorkspaceSubscriptions: GetWorkspaceSubscriptions + downscaleWorkspaceSubscription: DownscaleWorkspaceSubscription + updateWorkspaceSubscription: UpsertWorkspaceSubscription + logger: Logger }) => async () => { - const workspaceSubscriptions = await getWorkspaceSubscriptions() - for (const workspaceSubscription of workspaceSubscriptions) { - const workspaceId = workspaceSubscription.workspaceId - workspaceSubscription.subscriptionData - - const workspacePlan = await getWorkspacePlan({ workspaceId }) - if (!workspacePlan) throw new WorkspacePlanNotFoundError() - - switch (workspacePlan.name) { - case 'team': - case 'pro': - case 'business': - break - case 'unlimited': - case 'academia': - throw new WorkspacePlanMismatchError() - default: - throwUncoveredError(workspacePlan) + const subscriptions = await getWorkspaceSubscriptions() + for (const workspaceSubscription of subscriptions) { + const log = logger.child({ workspaceId: workspaceSubscription.workspaceId }) + try { + const subDownscaled = await downscaleWorkspaceSubscription({ + workspaceSubscription + }) + if (subDownscaled) { + log.info( + 'Downscaled workspace subscription to match the current workspace team' + ) + } else { + log.info('Did not need to downscale the workspace subscription') + } + } catch (err) { + log.error({ err }, 'Failed to downscale workspace subscription') } - - const [guestCount, memberCount, adminCount] = await Promise.all([ - countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:guest' }), - countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:member' }), - countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:admin' }) - ]) - - const subscriptionData = cloneDeep(workspaceSubscription.subscriptionData) - - mutateSubscriptionDataWithNewValidSeatNumbers({ - seatCount: guestCount, - workspacePlan: 'guest', - getWorkspacePlanProductId, - subscriptionData + const newBillingCycleEnd = calculateNewBillingCycleEnd({ workspaceSubscription }) + const updatedWorkspaceSubscription = { + ...workspaceSubscription, + currentBillingCycleEnd: newBillingCycleEnd + } + await updateWorkspaceSubscription({ + workspaceSubscription: updatedWorkspaceSubscription }) - mutateSubscriptionDataWithNewValidSeatNumbers({ - seatCount: memberCount + adminCount, - workspacePlan: workspacePlan.name, - getWorkspacePlanProductId, - subscriptionData - }) - - if (!isEqual(subscriptionData, workspaceSubscription.subscriptionData)) - await reconcileSubscriptionData({ subscriptionData, applyProrotation: false }) + log.info({ updatedWorkspaceSubscription }, 'Updated workspace billing cycle end') } } diff --git a/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts b/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts index 25e082a87..27fac3509 100644 --- a/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts +++ b/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts @@ -1,3 +1,4 @@ +import { logger } from '@/logging/logging' import { SubscriptionData, SubscriptionDataInput, @@ -11,7 +12,9 @@ import { } from '@/modules/gatekeeper/errors/billing' import { addWorkspaceSubscriptionSeatIfNeededFactory, - handleSubscriptionUpdateFactory + downscaleWorkspaceSubscriptionFactory, + handleSubscriptionUpdateFactory, + manageSubscriptionDownscaleFactory } from '@/modules/gatekeeper/services/subscriptions' import { expectToThrow } from '@/test/assertionHelper' import { throwUncoveredError } from '@speckle/shared' @@ -620,4 +623,201 @@ describe('subscriptions @gatekeeper', () => { }) }) }) + describe('downscaleWorkspaceSubscriptionFactory', () => { + it('throws an error if the workspace has no plan attached to it', async () => { + const subscriptionData = createTestSubscriptionData() + const workspaceSubscription = createTestWorkspaceSubscription({ + subscriptionData + }) + const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({ + getWorkspacePlan: async () => null, + countWorkspaceRole: async () => { + expect.fail() + }, + getWorkspacePlanProductId: () => { + expect.fail() + }, + reconcileSubscriptionData: async () => { + expect.fail() + } + }) + const err = await expectToThrow(async () => { + await downscaleSubscription({ workspaceSubscription }) + }) + expect(err.message).to.equal(new WorkspacePlanNotFoundError().message) + }) + it('throws an error if workspacePlan is not a paid plan', async () => { + const workspaceId = cryptoRandomString({ length: 10 }) + const subscriptionData = createTestSubscriptionData() + const workspaceSubscription = createTestWorkspaceSubscription({ + subscriptionData, + workspaceId + }) + const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({ + getWorkspacePlan: async () => ({ + name: 'unlimited', + workspaceId, + status: 'valid' + }), + countWorkspaceRole: async () => { + expect.fail() + }, + getWorkspacePlanProductId: () => { + expect.fail() + }, + reconcileSubscriptionData: async () => { + expect.fail() + } + }) + const err = await expectToThrow(async () => { + await downscaleSubscription({ workspaceSubscription }) + }) + expect(err.message).to.equal(new WorkspacePlanMismatchError().message) + }) + it('does not reconcile the subscription seats did not change', async () => { + const workspaceId = cryptoRandomString({ length: 10 }) + const priceId = cryptoRandomString({ length: 10 }) + const productId = cryptoRandomString({ length: 10 }) + const quantity = 10 + const subscriptionItemId = cryptoRandomString({ length: 10 }) + const subscriptionData = createTestSubscriptionData({ + products: [{ priceId, productId, quantity, subscriptionItemId }] + }) + const workspaceSubscription = createTestWorkspaceSubscription({ + subscriptionData, + billingInterval: 'monthly', + currentBillingCycleEnd: new Date(2034, 11, 5), + workspaceId + }) + const workspacePlanName = 'pro' + const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({ + getWorkspacePlan: async () => ({ + name: workspacePlanName, + workspaceId, + status: 'valid' + }), + countWorkspaceRole: async ({ workspaceRole }) => { + return workspaceRole === 'workspace:guest' ? 0 : 5 // 5+5 will be 10 as quantity + }, + getWorkspacePlanProductId: ({ workspacePlan }) => { + return workspacePlan === workspacePlanName + ? productId + : cryptoRandomString({ length: 10 }) + }, + reconcileSubscriptionData: async () => { + expect.fail() + } + }) + await downscaleSubscription({ workspaceSubscription }) + }) + it('reconciles the subscription to the new seat values', async () => { + const workspaceId = cryptoRandomString({ length: 10 }) + const proPriceId = cryptoRandomString({ length: 10 }) + const proProductId = cryptoRandomString({ length: 10 }) + const proQuantity = 10 + const proSubscriptionItemId = cryptoRandomString({ length: 10 }) + + const guestPriceId = cryptoRandomString({ length: 10 }) + const guestProductId = cryptoRandomString({ length: 10 }) + const guestQuantity = 10 + const guestSubscriptionItemId = cryptoRandomString({ length: 10 }) + const subscriptionData = createTestSubscriptionData({ + products: [ + { + priceId: proPriceId, + productId: proProductId, + quantity: proQuantity, + subscriptionItemId: proSubscriptionItemId + }, + { + priceId: guestPriceId, + productId: guestProductId, + quantity: guestQuantity, + subscriptionItemId: guestSubscriptionItemId + } + ] + }) + const testWorkspaceSubscription = createTestWorkspaceSubscription({ + subscriptionData, + workspaceId + }) + const workspacePlanName = 'pro' + + let reconciledSub: SubscriptionDataInput | undefined = undefined + const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({ + getWorkspacePlan: async () => ({ + name: workspacePlanName, + workspaceId, + status: 'valid' + }), + countWorkspaceRole: async ({ workspaceRole }) => { + return workspaceRole === 'workspace:guest' + ? guestQuantity / 2 + : proQuantity / 2 //we're halving the guest seats, regulars stay the same + }, + getWorkspacePlanProductId: ({ workspacePlan }) => { + return workspacePlan === workspacePlanName ? proProductId : guestProductId + }, + reconcileSubscriptionData: async ({ subscriptionData }) => { + reconciledSub = subscriptionData + } + }) + await downscaleSubscription({ workspaceSubscription: testWorkspaceSubscription }) + + expect( + reconciledSub!.products.find((p) => p.productId === proProductId)?.quantity + ).to.be.equal(proQuantity) + expect( + reconciledSub!.products.find((p) => p.productId === guestProductId)?.quantity + ).to.be.equal(guestQuantity / 2) + }) + }) + describe('manageSubscriptionDownscaleFactory', () => { + it('still updates the monthly billing cycle end, even if subscription reconciliation fails', async () => { + const testWorkspaceSubscription = createTestWorkspaceSubscription({ + billingInterval: 'monthly', + currentBillingCycleEnd: new Date(2034, 11, 5) + }) + let updatedWorkspaceSubscription: WorkspaceSubscription | undefined = undefined + await manageSubscriptionDownscaleFactory({ + logger, + getWorkspaceSubscriptions: async () => [testWorkspaceSubscription], + downscaleWorkspaceSubscription: async () => { + throw 'kabumm' + }, + updateWorkspaceSubscription: async ({ workspaceSubscription }) => { + updatedWorkspaceSubscription = workspaceSubscription + } + })() + + const updatedBillingCycleEnd = new Date(2035, 0, 5) + expect(updatedWorkspaceSubscription).deep.equal({ + ...testWorkspaceSubscription, + currentBillingCycleEnd: updatedBillingCycleEnd + }) + }) + it('still updates the yearly billing cycle end, even if subscription reconciliation fails', async () => { + const testWorkspaceSubscription = createTestWorkspaceSubscription({ + billingInterval: 'yearly', + currentBillingCycleEnd: new Date(2034, 11, 5) + }) + let updatedWorkspaceSubscription: WorkspaceSubscription | undefined = undefined + await manageSubscriptionDownscaleFactory({ + logger, + getWorkspaceSubscriptions: async () => [testWorkspaceSubscription], + downscaleWorkspaceSubscription: async () => { + throw 'kabumm' + }, + updateWorkspaceSubscription: async ({ workspaceSubscription }) => { + updatedWorkspaceSubscription = workspaceSubscription + } + })() + + const updatedBillingCycleEnd = new Date(2035, 11, 5) + expect(updatedWorkspaceSubscription).deep.equal({ + ...testWorkspaceSubscription, + currentBillingCycleEnd: updatedBillingCycleEnd + }) + }) + }) }) From 796b1c23edd1eab6d1d457e6811f5f6e974d21fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Tue, 29 Oct 2024 17:44:43 +0100 Subject: [PATCH 44/48] feat(gatekeeper): get workspace subscriptions, that are about to expire --- .../gatekeeper/repositories/billing.ts | 14 ++- .../modules/gatekeeper/tests/helpers.ts | 40 ++++++++ .../intergration/billingRepositories.spec.ts | 99 ++++++++++--------- .../tests/unit/subscriptions.spec.ts | 40 +------- 4 files changed, 112 insertions(+), 81 deletions(-) create mode 100644 packages/server/modules/gatekeeper/tests/helpers.ts diff --git a/packages/server/modules/gatekeeper/repositories/billing.ts b/packages/server/modules/gatekeeper/repositories/billing.ts index be31356fb..767c4e020 100644 --- a/packages/server/modules/gatekeeper/repositories/billing.ts +++ b/packages/server/modules/gatekeeper/repositories/billing.ts @@ -12,7 +12,8 @@ import { DeleteCheckoutSession, GetWorkspaceCheckoutSession, GetWorkspaceSubscription, - GetWorkspaceSubscriptionBySubscriptionId + GetWorkspaceSubscriptionBySubscriptionId, + GetWorkspaceSubscriptions } from '@/modules/gatekeeper/domain/billing' import { Knex } from 'knex' @@ -127,3 +128,14 @@ export const getWorkspaceSubscriptionBySubscriptionIdFactory = .first() return subscription ?? null } + +export const getWorkspaceSubscriptionsAboutToEndBillingCycleFactory = + ({ db }: { db: Knex }): GetWorkspaceSubscriptions => + async () => { + const cycleEnd = new Date() + cycleEnd.setMinutes(cycleEnd.getMinutes() + 5) + return await tables + .workspaceSubscriptions(db) + .select() + .where('currentBillingCycleEnd', '<', cycleEnd) + } diff --git a/packages/server/modules/gatekeeper/tests/helpers.ts b/packages/server/modules/gatekeeper/tests/helpers.ts new file mode 100644 index 000000000..b55bb5205 --- /dev/null +++ b/packages/server/modules/gatekeeper/tests/helpers.ts @@ -0,0 +1,40 @@ +import { + SubscriptionData, + WorkspaceSubscription +} from '@/modules/gatekeeper/domain/billing' +import cryptoRandomString from 'crypto-random-string' +import { assign } from 'lodash' + +export const createTestSubscriptionData = ( + overrides: Partial = {} +): SubscriptionData => { + const defaultValues: SubscriptionData = { + cancelAt: null, + customerId: cryptoRandomString({ length: 10 }), + products: [ + { + priceId: cryptoRandomString({ length: 10 }), + productId: cryptoRandomString({ length: 10 }), + quantity: 3, + subscriptionItemId: cryptoRandomString({ length: 10 }) + } + ], + status: 'active', + subscriptionId: cryptoRandomString({ length: 10 }) + } + return assign(defaultValues, overrides) +} + +export const createTestWorkspaceSubscription = ( + overrides: Partial = {} +): WorkspaceSubscription => { + const defaultValues: WorkspaceSubscription = { + billingInterval: 'monthly', + createdAt: new Date(), + updatedAt: new Date(), + currentBillingCycleEnd: new Date(), + subscriptionData: createTestSubscriptionData(), + workspaceId: cryptoRandomString({ length: 10 }) + } + return assign(defaultValues, overrides) +} diff --git a/packages/server/modules/gatekeeper/tests/intergration/billingRepositories.spec.ts b/packages/server/modules/gatekeeper/tests/intergration/billingRepositories.spec.ts index 013cb2db9..c48fb25db 100644 --- a/packages/server/modules/gatekeeper/tests/intergration/billingRepositories.spec.ts +++ b/packages/server/modules/gatekeeper/tests/intergration/billingRepositories.spec.ts @@ -1,5 +1,4 @@ import db from '@/db/knex' -import { WorkspaceSubscription } from '@/modules/gatekeeper/domain/billing' import { deleteCheckoutSessionFactory, getCheckoutSessionFactory, @@ -10,9 +9,15 @@ import { updateCheckoutSessionStatusFactory, upsertPaidWorkspacePlanFactory, getWorkspaceSubscriptionFactory, - getWorkspaceSubscriptionBySubscriptionIdFactory + getWorkspaceSubscriptionBySubscriptionIdFactory, + getWorkspaceSubscriptionsAboutToEndBillingCycleFactory } from '@/modules/gatekeeper/repositories/billing' +import { + createTestSubscriptionData, + createTestWorkspaceSubscription +} from '@/modules/gatekeeper/tests/helpers' import { upsertWorkspaceFactory } from '@/modules/workspaces/repositories/workspaces' +import { truncateTables } from '@/test/hooks' import { createAndStoreTestWorkspaceFactory } from '@/test/speckle-helpers/workspaces' import { expect } from 'chai' import cryptoRandomString from 'crypto-random-string' @@ -33,6 +38,9 @@ const getWorkspaceSubscription = getWorkspaceSubscriptionFactory({ db }) const getWorkspaceSubscriptionBySubscriptionId = getWorkspaceSubscriptionBySubscriptionIdFactory({ db }) +const getSubscriptionsAboutToEndBillingCycle = + getWorkspaceSubscriptionsAboutToEndBillingCycleFactory({ db }) + describe('billing repositories @gatekeeper', () => { describe('workspacePlans', () => { describe('upsertPaidWorkspacePlanFactory creates a function, that', () => { @@ -204,27 +212,21 @@ describe('billing repositories @gatekeeper', () => { it('saves and updates the subscription', async () => { const workspace = await createAndStoreTestWorkspace() const workspaceId = workspace.id - const workspaceSubscription: WorkspaceSubscription = { - billingInterval: 'monthly' as const, - createdAt: new Date(), - updatedAt: new Date(), - currentBillingCycleEnd: new Date(), - subscriptionData: { - customerId: cryptoRandomString({ length: 10 }), - status: 'active' as const, - cancelAt: null, - products: [ - { - priceId: cryptoRandomString({ length: 10 }), - quantity: 10, - productId: cryptoRandomString({ length: 10 }), - subscriptionItemId: cryptoRandomString({ length: 10 }) - } - ], - subscriptionId: cryptoRandomString({ length: 10 }) - }, - workspaceId - } + const subscriptionData = createTestSubscriptionData({ + products: [ + { + priceId: cryptoRandomString({ length: 10 }), + quantity: 10, + productId: cryptoRandomString({ length: 10 }), + subscriptionItemId: cryptoRandomString({ length: 10 }) + } + ] + }) + const workspaceSubscription = createTestWorkspaceSubscription({ + workspaceId, + billingInterval: 'monthly', + subscriptionData + }) await upsertWorkspaceSubscription({ workspaceSubscription }) let storedSubscription = await getWorkspaceSubscription({ workspaceId }) expect(storedSubscription).deep.equal(workspaceSubscription) @@ -255,27 +257,7 @@ describe('billing repositories @gatekeeper', () => { it('returns the sub', async () => { const workspace = await createAndStoreTestWorkspace() const workspaceId = workspace.id - const workspaceSubscription: WorkspaceSubscription = { - billingInterval: 'monthly' as const, - createdAt: new Date(), - updatedAt: new Date(), - currentBillingCycleEnd: new Date(), - subscriptionData: { - customerId: cryptoRandomString({ length: 10 }), - status: 'active' as const, - cancelAt: null, - products: [ - { - priceId: cryptoRandomString({ length: 10 }), - quantity: 10, - productId: cryptoRandomString({ length: 10 }), - subscriptionItemId: cryptoRandomString({ length: 10 }) - } - ], - subscriptionId: cryptoRandomString({ length: 10 }) - }, - workspaceId - } + const workspaceSubscription = createTestWorkspaceSubscription({ workspaceId }) await upsertWorkspaceSubscription({ workspaceSubscription }) const storedSubscription = await getWorkspaceSubscriptionBySubscriptionId({ subscriptionId: workspaceSubscription.subscriptionData.subscriptionId @@ -283,5 +265,34 @@ describe('billing repositories @gatekeeper', () => { expect(storedSubscription).deep.equal(workspaceSubscription) }) }) + describe('getWorkspaceSubscriptionsAboutToEndBillingCycle', () => { + before(async () => { + await truncateTables(['workspace_subscriptions']) + }) + it('returns subs, that are about to end their billing cycle', async () => { + const workspace1 = await createAndStoreTestWorkspace() + const workspace1Id = workspace1.id + const workspace1Subscription = createTestWorkspaceSubscription({ + workspaceId: workspace1Id, + currentBillingCycleEnd: new Date(2099, 0, 1) + }) + await upsertWorkspaceSubscription({ + workspaceSubscription: workspace1Subscription + }) + + const workspace2 = await createAndStoreTestWorkspace() + const workspace2Id = workspace2.id + const currentBillingCycleEnd = new Date() + currentBillingCycleEnd.setMinutes(currentBillingCycleEnd.getMinutes() + 4) + const workspace2Subscription = createTestWorkspaceSubscription({ + workspaceId: workspace2Id + }) + await upsertWorkspaceSubscription({ + workspaceSubscription: workspace2Subscription + }) + const subscriptions = await getSubscriptionsAboutToEndBillingCycle() + expect(subscriptions).deep.equalInAnyOrder([workspace2Subscription]) + }) + }) }) }) diff --git a/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts b/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts index 27fac3509..55f262891 100644 --- a/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts +++ b/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts @@ -1,6 +1,5 @@ import { logger } from '@/logging/logging' import { - SubscriptionData, SubscriptionDataInput, WorkspacePlan, WorkspaceSubscription @@ -16,45 +15,14 @@ import { handleSubscriptionUpdateFactory, manageSubscriptionDownscaleFactory } from '@/modules/gatekeeper/services/subscriptions' +import { + createTestSubscriptionData, + createTestWorkspaceSubscription +} from '@/modules/gatekeeper/tests/helpers' import { expectToThrow } from '@/test/assertionHelper' import { throwUncoveredError } from '@speckle/shared' import { expect } from 'chai' import cryptoRandomString from 'crypto-random-string' -import { assign } from 'lodash' - -const createTestSubscriptionData = ( - overrides: Partial = {} -): SubscriptionData => { - const defaultValues: SubscriptionData = { - cancelAt: null, - customerId: cryptoRandomString({ length: 10 }), - products: [ - { - priceId: cryptoRandomString({ length: 10 }), - productId: cryptoRandomString({ length: 10 }), - quantity: 3, - subscriptionItemId: cryptoRandomString({ length: 10 }) - } - ], - status: 'active', - subscriptionId: cryptoRandomString({ length: 10 }) - } - return assign(defaultValues, overrides) -} - -const createTestWorkspaceSubscription = ( - overrides: Partial = {} -): WorkspaceSubscription => { - const defaultValues: WorkspaceSubscription = { - billingInterval: 'monthly', - createdAt: new Date(), - updatedAt: new Date(), - currentBillingCycleEnd: new Date(), - subscriptionData: createTestSubscriptionData(), - workspaceId: cryptoRandomString({ length: 10 }) - } - return assign(defaultValues, overrides) -} describe('subscriptions @gatekeeper', () => { describe('handleSubscriptionUpdateFactory creates a function, that', () => { From 81c20dbdf3c93903600cf62dfdd6e10220220e24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Tue, 29 Oct 2024 20:17:19 +0100 Subject: [PATCH 45/48] feat(gatekeeper): manage subscription downscale --- .../lib/common/generated/gql/graphql.ts | 2 + .../modules/core/graph/generated/graphql.ts | 2 + .../modules/core/services/taskScheduler.ts | 4 +- .../graph/generated/graphql.ts | 2 + .../modules/gatekeeper/clients/stripe.ts | 8 ++++ packages/server/modules/gatekeeper/index.ts | 34 +++++++++++++-- .../gatekeeper/repositories/billing.ts | 2 +- .../gatekeeper/services/subscriptions.ts | 8 +++- .../intergration/billingRepositories.spec.ts | 6 +-- .../tests/unit/subscriptions.spec.ts | 41 ++++++++++++++----- .../server/test/graphql/generated/graphql.ts | 2 + 11 files changed, 88 insertions(+), 23 deletions(-) diff --git a/packages/frontend-2/lib/common/generated/gql/graphql.ts b/packages/frontend-2/lib/common/generated/gql/graphql.ts index c2247e93b..2485c0008 100644 --- a/packages/frontend-2/lib/common/generated/gql/graphql.ts +++ b/packages/frontend-2/lib/common/generated/gql/graphql.ts @@ -4196,7 +4196,9 @@ export type WorkspacePlan = { }; export enum WorkspacePlanStatuses { + CancelationScheduled = 'cancelationScheduled', Canceled = 'canceled', + Expired = 'expired', PaymentFailed = 'paymentFailed', Trial = 'trial', Valid = 'valid' diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index 508929ba2..eb55651d0 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -4216,7 +4216,9 @@ export type WorkspacePlan = { }; export enum WorkspacePlanStatuses { + CancelationScheduled = 'cancelationScheduled', Canceled = 'canceled', + Expired = 'expired', PaymentFailed = 'paymentFailed', Trial = 'trial', Valid = 'valid' diff --git a/packages/server/modules/core/services/taskScheduler.ts b/packages/server/modules/core/services/taskScheduler.ts index c627302dd..7554c000f 100644 --- a/packages/server/modules/core/services/taskScheduler.ts +++ b/packages/server/modules/core/services/taskScheduler.ts @@ -1,7 +1,7 @@ import cron from 'node-cron' import { InvalidArgumentError } from '@/modules/shared/errors' import { ensureError } from '@/modules/shared/helpers/errorHelper' -import { activitiesLogger } from '@/logging/logging' +import { logger } from '@/logging/logging' import { AcquireTaskLock, ReleaseTaskLock, @@ -16,7 +16,7 @@ export const scheduledCallbackWrapper = async ( acquireLock: AcquireTaskLock, releaseTaskLock: ReleaseTaskLock ) => { - const boundLogger = activitiesLogger.child({ taskName }) + const boundLogger = logger.child({ taskName }) // try to acquire the task lock with the function name and a new expiration date const lockExpiresAt = new Date(scheduledTime.getTime() + lockTimeout) const lock = await acquireLock({ taskName, lockExpiresAt }) 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 5ccc912cf..975f33f61 100644 --- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts +++ b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts @@ -4199,7 +4199,9 @@ export type WorkspacePlan = { }; export enum WorkspacePlanStatuses { + CancelationScheduled = 'cancelationScheduled', Canceled = 'canceled', + Expired = 'expired', PaymentFailed = 'paymentFailed', Trial = 'trial', Valid = 'valid' diff --git a/packages/server/modules/gatekeeper/clients/stripe.ts b/packages/server/modules/gatekeeper/clients/stripe.ts index 8d68d64b6..371985760 100644 --- a/packages/server/modules/gatekeeper/clients/stripe.ts +++ b/packages/server/modules/gatekeeper/clients/stripe.ts @@ -187,6 +187,14 @@ export const reconcileWorkspaceSubscriptionFactory = }) } } + // remove products from the sub + const productIds = subscriptionData.products.map((p) => p.productId) + const removedProducts = existingSubscriptionState.products.filter( + (p) => !productIds.includes(p.productId) + ) + for (const removedProduct of removedProducts) { + items.push({ id: removedProduct.subscriptionItemId, deleted: true }) + } // workspaceSubscription.subscriptionData.products. // const item = workspaceSubscription.subscriptionData.products.find(p => p.) await stripe.subscriptions.update(subscriptionData.subscriptionId, { diff --git a/packages/server/modules/gatekeeper/index.ts b/packages/server/modules/gatekeeper/index.ts index 0cfe0efed..7b1de0c35 100644 --- a/packages/server/modules/gatekeeper/index.ts +++ b/packages/server/modules/gatekeeper/index.ts @@ -1,5 +1,5 @@ import cron from 'node-cron' -import { moduleLogger } from '@/logging/logging' +import { logger, 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' @@ -8,12 +8,23 @@ import { registerOrUpdateScopeFactory } from '@/modules/shared/repositories/scop import { db } from '@/db/knex' import { gatekeeperScopes } from '@/modules/gatekeeper/scopes' import { initializeEventListenersFactory } from '@/modules/gatekeeper/events/eventListener' -import { getStripeClient } from '@/modules/gatekeeper/stripe' +import { getStripeClient, getWorkspacePlanProductId } from '@/modules/gatekeeper/stripe' import { scheduleExecutionFactory } from '@/modules/core/services/taskScheduler' import { acquireTaskLockFactory, releaseTaskLockFactory } from '@/modules/core/repositories/scheduledTasks' +import { + downscaleWorkspaceSubscriptionFactory, + manageSubscriptionDownscaleFactory +} from '@/modules/gatekeeper/services/subscriptions' +import { + getWorkspacePlanFactory, + getWorkspaceSubscriptionsPastBillingCycleEndFactory, + upsertWorkspaceSubscriptionFactory +} from '@/modules/gatekeeper/repositories/billing' +import { countWorkspaceRoleWithOptionalProjectRoleFactory } from '@/modules/workspaces/repositories/workspaces' +import { reconcileWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/clients/stripe' const { FF_GATEKEEPER_MODULE_ENABLED, FF_BILLING_INTEGRATION_ENABLED } = getFeatureFlags() @@ -29,14 +40,29 @@ const scheduleWorkspaceSubscriptionDownscale = () => { releaseTaskLock: releaseTaskLockFactory({ db }) }) + const stripe = getStripeClient() + + const manageSubscriptionDownscale = manageSubscriptionDownscaleFactory({ + logger, + downscaleWorkspaceSubscription: downscaleWorkspaceSubscriptionFactory({ + countWorkspaceRole: countWorkspaceRoleWithOptionalProjectRoleFactory({ db }), + getWorkspacePlan: getWorkspacePlanFactory({ db }), + reconcileSubscriptionData: reconcileWorkspaceSubscriptionFactory({ stripe }), + getWorkspacePlanProductId + }), + getWorkspaceSubscriptions: getWorkspaceSubscriptionsPastBillingCycleEndFactory({ + db + }), + updateWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({ db }) + }) + const cronExpression = '*/10 * * * * *' return scheduleExecution( cronExpression, 'WorkspaceSubscriptionDownscale', async () => { - moduleLogger.info('Starting workspace subscription downscale scan') + await manageSubscriptionDownscale() // await cleanOrphanedWebhookConfigs() - moduleLogger.info('Finished cleanup') } ) } diff --git a/packages/server/modules/gatekeeper/repositories/billing.ts b/packages/server/modules/gatekeeper/repositories/billing.ts index 767c4e020..986805d30 100644 --- a/packages/server/modules/gatekeeper/repositories/billing.ts +++ b/packages/server/modules/gatekeeper/repositories/billing.ts @@ -129,7 +129,7 @@ export const getWorkspaceSubscriptionBySubscriptionIdFactory = return subscription ?? null } -export const getWorkspaceSubscriptionsAboutToEndBillingCycleFactory = +export const getWorkspaceSubscriptionsPastBillingCycleEndFactory = ({ db }: { db: Knex }): GetWorkspaceSubscriptions => async () => { const cycleEnd = new Date() diff --git a/packages/server/modules/gatekeeper/services/subscriptions.ts b/packages/server/modules/gatekeeper/services/subscriptions.ts index 67dc2cc66..e706ddd72 100644 --- a/packages/server/modules/gatekeeper/services/subscriptions.ts +++ b/packages/server/modules/gatekeeper/services/subscriptions.ts @@ -112,7 +112,8 @@ export const addWorkspaceSubscriptionSeatIfNeededFactory = }) => async ({ workspaceId, role }: { workspaceId: string; role: WorkspaceRoles }) => { const workspacePlan = await getWorkspacePlan({ workspaceId }) - if (!workspacePlan) throw new WorkspacePlanNotFoundError() + // if (!workspacePlan) throw new WorkspacePlanNotFoundError() + if (!workspacePlan) return const workspaceSubscription = await getWorkspaceSubscription({ workspaceId }) if (!workspaceSubscription) throw new WorkspaceSubscriptionNotFoundError() @@ -193,7 +194,10 @@ const mutateSubscriptionDataWithNewValidSeatNumbers = ({ if (seatCount < 0) throw new Error('Invalid seat count, cannot be negative') if (seatCount === 0 && product === undefined) return - if (product !== undefined && product.quantity >= seatCount) { + if (seatCount === 0 && product !== undefined) { + const prodIndex = subscriptionData.products.indexOf(product) + subscriptionData.products.splice(prodIndex, 1) + } else if (product !== undefined && product.quantity >= seatCount) { product.quantity = seatCount } else { throw new Error('Invalid subscription state') diff --git a/packages/server/modules/gatekeeper/tests/intergration/billingRepositories.spec.ts b/packages/server/modules/gatekeeper/tests/intergration/billingRepositories.spec.ts index c48fb25db..4bdb178c4 100644 --- a/packages/server/modules/gatekeeper/tests/intergration/billingRepositories.spec.ts +++ b/packages/server/modules/gatekeeper/tests/intergration/billingRepositories.spec.ts @@ -10,7 +10,7 @@ import { upsertPaidWorkspacePlanFactory, getWorkspaceSubscriptionFactory, getWorkspaceSubscriptionBySubscriptionIdFactory, - getWorkspaceSubscriptionsAboutToEndBillingCycleFactory + getWorkspaceSubscriptionsPastBillingCycleEndFactory } from '@/modules/gatekeeper/repositories/billing' import { createTestSubscriptionData, @@ -39,7 +39,7 @@ const getWorkspaceSubscriptionBySubscriptionId = getWorkspaceSubscriptionBySubscriptionIdFactory({ db }) const getSubscriptionsAboutToEndBillingCycle = - getWorkspaceSubscriptionsAboutToEndBillingCycleFactory({ db }) + getWorkspaceSubscriptionsPastBillingCycleEndFactory({ db }) describe('billing repositories @gatekeeper', () => { describe('workspacePlans', () => { @@ -265,7 +265,7 @@ describe('billing repositories @gatekeeper', () => { expect(storedSubscription).deep.equal(workspaceSubscription) }) }) - describe('getWorkspaceSubscriptionsAboutToEndBillingCycle', () => { + describe('getWorkspaceSubscriptionsPastBillingCycleEndFactory', () => { before(async () => { await truncateTables(['workspace_subscriptions']) }) diff --git a/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts b/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts index 55f262891..b0d070045 100644 --- a/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts +++ b/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts @@ -23,6 +23,7 @@ import { expectToThrow } from '@/test/assertionHelper' import { throwUncoveredError } from '@speckle/shared' import { expect } from 'chai' import cryptoRandomString from 'crypto-random-string' +import { omit } from 'lodash' describe('subscriptions @gatekeeper', () => { describe('handleSubscriptionUpdateFactory creates a function, that', () => { @@ -109,7 +110,12 @@ describe('subscriptions @gatekeeper', () => { } })({ subscriptionData }) expect(updatedPlan!.status).to.be.equal('cancelationScheduled') - expect(updatedSubscription).deep.equal(workspaceSubscription) + expect(updatedSubscription!.updatedAt).to.be.greaterThanOrEqual( + workspaceSubscription.updatedAt + ) + expect(omit(updatedSubscription!, 'updatedAt')).deep.equal( + omit(workspaceSubscription, 'updatedAt') + ) }) it('sets the state to valid', async () => { const subscriptionData = createTestSubscriptionData({ @@ -140,7 +146,12 @@ describe('subscriptions @gatekeeper', () => { } })({ subscriptionData }) expect(updatedPlan!.status).to.be.equal('valid') - expect(updatedSubscription).deep.equal(workspaceSubscription) + expect(updatedSubscription!.updatedAt).to.be.greaterThanOrEqual( + workspaceSubscription.updatedAt + ) + expect(omit(updatedSubscription!, 'updatedAt')).deep.equal( + omit(workspaceSubscription, 'updatedAt') + ) }) it('sets the state to paymentFailed', async () => { const subscriptionData = createTestSubscriptionData({ @@ -167,7 +178,12 @@ describe('subscriptions @gatekeeper', () => { } })({ subscriptionData }) expect(updatedPlan!.status).to.be.equal('paymentFailed') - expect(updatedSubscription).deep.equal(workspaceSubscription) + expect(updatedSubscription!.updatedAt).to.be.greaterThanOrEqual( + workspaceSubscription.updatedAt + ) + expect(omit(updatedSubscription!, 'updatedAt')).deep.equal( + omit(workspaceSubscription, 'updatedAt') + ) }) it('sets the state to canceled', async () => { const subscriptionData = createTestSubscriptionData({ @@ -197,7 +213,12 @@ describe('subscriptions @gatekeeper', () => { } })({ subscriptionData }) expect(updatedPlan!.status).to.be.equal('canceled') - expect(updatedSubscription).deep.equal(workspaceSubscription) + expect(updatedSubscription!.updatedAt).to.be.greaterThanOrEqual( + workspaceSubscription.updatedAt + ) + expect(omit(updatedSubscription!, 'updatedAt')).deep.equal( + omit(workspaceSubscription, 'updatedAt') + ) }) ;( ['incomplete', 'incomplete_expired', 'trialing', 'unpaid', 'paused'] as const @@ -231,7 +252,7 @@ describe('subscriptions @gatekeeper', () => { }) }) describe('addWorkspaceSubscriptionSeatIfNeededFactory returns a function, that', () => { - it('throws if the workspacePlan is not found', async () => { + it('just returns if the workspacePlan is not found', async () => { const workspaceId = cryptoRandomString({ length: 10 }) const addWorkspaceSubscriptionSeatIfNeeded = addWorkspaceSubscriptionSeatIfNeededFactory({ @@ -252,13 +273,11 @@ describe('subscriptions @gatekeeper', () => { expect.fail() } }) - const err = await expectToThrow(async () => { - await addWorkspaceSubscriptionSeatIfNeeded({ - workspaceId, - role: 'workspace:admin' - }) + await addWorkspaceSubscriptionSeatIfNeeded({ + workspaceId, + role: 'workspace:admin' }) - expect(err.message).to.equal(new WorkspacePlanNotFoundError().message) + expect(true).to.be.true }) it('throws if the workspaceSubscription is not found', async () => { const workspaceId = cryptoRandomString({ length: 10 }) diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index 48bec9e37..18628b86e 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -4200,7 +4200,9 @@ export type WorkspacePlan = { }; export enum WorkspacePlanStatuses { + CancelationScheduled = 'cancelationScheduled', Canceled = 'canceled', + Expired = 'expired', PaymentFailed = 'paymentFailed', Trial = 'trial', Valid = 'valid' From edc70b76bcea1fc564a2c2e014947440b6e060c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Tue, 29 Oct 2024 20:44:13 +0100 Subject: [PATCH 46/48] fix(gatekeeper): do not update subscription to canceled subs --- .../gatekeeper/services/subscriptions.ts | 4 ++ .../tests/unit/subscriptions.spec.ts | 59 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/packages/server/modules/gatekeeper/services/subscriptions.ts b/packages/server/modules/gatekeeper/services/subscriptions.ts index e706ddd72..0bff65f34 100644 --- a/packages/server/modules/gatekeeper/services/subscriptions.ts +++ b/packages/server/modules/gatekeeper/services/subscriptions.ts @@ -129,6 +129,8 @@ export const addWorkspaceSubscriptionSeatIfNeededFactory = throwUncoveredError(workspacePlan) } + if (workspacePlan.status === 'canceled') return + let productId: string let priceId: string let roleCount: number @@ -257,6 +259,8 @@ export const downscaleWorkspaceSubscriptionFactory = throwUncoveredError(workspacePlan) } + if (workspacePlan.status === 'canceled') return false + const [guestCount, memberCount, adminCount] = await Promise.all([ countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:guest' }), countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:member' }), diff --git a/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts b/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts index b0d070045..94bb7f3e5 100644 --- a/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts +++ b/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts @@ -346,6 +346,39 @@ describe('subscriptions @gatekeeper', () => { }) expect(err.message).to.equal(new WorkspacePlanMismatchError().message) }) + it('returns without reconciliation if the subscription is canceled', async () => { + const workspaceId = cryptoRandomString({ length: 10 }) + const subscriptionData = createTestSubscriptionData({ products: [] }) + const workspaceSubscription = createTestWorkspaceSubscription({ + workspaceId, + subscriptionData + }) + const addWorkspaceSubscriptionSeatIfNeeded = + addWorkspaceSubscriptionSeatIfNeededFactory({ + getWorkspacePlan: async () => ({ + name: 'pro', + workspaceId, + status: 'canceled' + }), + getWorkspaceSubscription: async () => workspaceSubscription, + countWorkspaceRole: async () => { + expect.fail() + }, + getWorkspacePlanPrice: () => { + expect.fail() + }, + getWorkspacePlanProductId: () => { + expect.fail() + }, + reconcileSubscriptionData: async () => { + expect.fail() + } + }) + await addWorkspaceSubscriptionSeatIfNeeded({ + workspaceId, + role: 'workspace:admin' + }) + }) it('uses the guest count, guest product and price id if the new role is workspace:guest', async () => { const workspaceId = cryptoRandomString({ length: 10 }) const subscriptionData = createTestSubscriptionData({ products: [] }) @@ -661,6 +694,32 @@ describe('subscriptions @gatekeeper', () => { }) expect(err.message).to.equal(new WorkspacePlanMismatchError().message) }) + it('returns if the subscription is canceled', async () => { + const workspaceId = cryptoRandomString({ length: 10 }) + const subscriptionData = createTestSubscriptionData() + const workspaceSubscription = createTestWorkspaceSubscription({ + subscriptionData, + workspaceId + }) + const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({ + getWorkspacePlan: async () => ({ + name: 'pro', + workspaceId, + status: 'canceled' + }), + countWorkspaceRole: async () => { + expect.fail() + }, + getWorkspacePlanProductId: () => { + expect.fail() + }, + reconcileSubscriptionData: async () => { + expect.fail() + } + }) + const hasDownscaled = await downscaleSubscription({ workspaceSubscription }) + expect(hasDownscaled).to.be.false + }) it('does not reconcile the subscription seats did not change', async () => { const workspaceId = cryptoRandomString({ length: 10 }) const priceId = cryptoRandomString({ length: 10 }) From ab331e27b288bfec56bd495ae614c6112e99ce14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Wed, 30 Oct 2024 05:33:12 +0100 Subject: [PATCH 47/48] ci: bump postgres and max connections --- .circleci/config.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 75c86289f..1055779de 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -439,11 +439,12 @@ jobs: docker: - image: cimg/node:18.19.0 - image: cimg/redis:7.2.4 - - image: cimg/postgres:14.11 + - image: cimg/postgres:17.0 environment: POSTGRES_DB: speckle2_test POSTGRES_PASSWORD: speckle POSTGRES_USER: speckle + command: -c 'max_connections=1000' - image: 'minio/minio' command: server /data --console-address ":9001" # environment: @@ -453,6 +454,7 @@ jobs: NODE_ENV: test DATABASE_URL: 'postgres://speckle:speckle@127.0.0.1:5432/speckle2_test' PGDATABASE: speckle2_test + POSTGRES_MAX_CONNECTIONS_SERVER: 20 PGUSER: speckle SESSION_SECRET: 'keyboard cat' STRATEGY_LOCAL: 'true' From 8e239ec744be1e308422cd016c7397d36a7a49b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Wed, 30 Oct 2024 08:57:34 +0100 Subject: [PATCH 48/48] feat(gatekeeper): feature access functions --- .../modules/gatekeeper/domain/operations.ts | 10 ++++ .../gatekeeper/domain/workspacePricing.ts | 9 +++- .../services/featureAuthorization.ts | 53 +++++++++++++++++++ .../tests/unit/featureAuthorization.spec.ts | 52 ++++++++++++++++++ 4 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 packages/server/modules/gatekeeper/domain/operations.ts create mode 100644 packages/server/modules/gatekeeper/services/featureAuthorization.ts create mode 100644 packages/server/modules/gatekeeper/tests/unit/featureAuthorization.spec.ts diff --git a/packages/server/modules/gatekeeper/domain/operations.ts b/packages/server/modules/gatekeeper/domain/operations.ts new file mode 100644 index 000000000..78cf4fef1 --- /dev/null +++ b/packages/server/modules/gatekeeper/domain/operations.ts @@ -0,0 +1,10 @@ +import { WorkspaceFeatureName } from '@/modules/gatekeeper/domain/workspacePricing' + +export type CanWorkspaceAccessFeature = (args: { + workspaceId: string + workspaceFeature: WorkspaceFeatureName +}) => Promise + +export type WorkspaceFeatureAccessFunction = (args: { + workspaceId: string +}) => Promise diff --git a/packages/server/modules/gatekeeper/domain/workspacePricing.ts b/packages/server/modules/gatekeeper/domain/workspacePricing.ts index e552fe102..9fc31db3d 100644 --- a/packages/server/modules/gatekeeper/domain/workspacePricing.ts +++ b/packages/server/modules/gatekeeper/domain/workspacePricing.ts @@ -1,6 +1,6 @@ import { z } from 'zod' -type Features = +export type WorkspaceFeatureName = | 'domainBasedSecurityPolicies' | 'oidcSso' | 'workspaceDataRegionSpecificity' @@ -10,7 +10,7 @@ type FeatureDetails = { description?: string } -const features: Record = { +const features: Record = { domainBasedSecurityPolicies: { description: 'Email domain based security policies', displayName: 'Domain security policies' @@ -146,6 +146,11 @@ export const unpaidWorkspacePlanFeatures: Record< unlimited } +export const workspacePlanFeatures: Record< + WorkspacePlans, + WorkspacePlanFeaturesAndLimits +> = { ...paidWorkspacePlanFeatures, ...unpaidWorkspacePlanFeatures } + export const pricingTable = { workspacePricingPlanInformation, workspacePlanInformation: paidWorkspacePlanFeatures diff --git a/packages/server/modules/gatekeeper/services/featureAuthorization.ts b/packages/server/modules/gatekeeper/services/featureAuthorization.ts new file mode 100644 index 000000000..aa1ea5c0e --- /dev/null +++ b/packages/server/modules/gatekeeper/services/featureAuthorization.ts @@ -0,0 +1,53 @@ +import { GetWorkspacePlan } from '@/modules/gatekeeper/domain/billing' +import { + CanWorkspaceAccessFeature, + WorkspaceFeatureAccessFunction +} from '@/modules/gatekeeper/domain/operations' +import { workspacePlanFeatures } from '@/modules/gatekeeper/domain/workspacePricing' +import { WorkspacePlanNotFoundError } from '@/modules/gatekeeper/errors/billing' +import { throwUncoveredError } from '@speckle/shared' + +export const canWorkspaceAccessFeatureFactory = + ({ + getWorkspacePlan + }: { + getWorkspacePlan: GetWorkspacePlan + }): CanWorkspaceAccessFeature => + async ({ workspaceId, workspaceFeature }) => { + const workspacePlan = await getWorkspacePlan({ workspaceId }) + if (!workspacePlan) throw new WorkspacePlanNotFoundError() + switch (workspacePlan.status) { + case 'valid': + case 'trial': + case 'paymentFailed': + case 'cancelationScheduled': + break + case 'expired': + case 'canceled': + return false + default: + throwUncoveredError(workspacePlan) + } + return workspacePlanFeatures[workspacePlan.name][workspaceFeature] + } + +export const canWorkspaceUseOidcSsoFactory = + (deps: { getWorkspacePlan: GetWorkspacePlan }): WorkspaceFeatureAccessFunction => + async ({ workspaceId }) => + canWorkspaceAccessFeatureFactory(deps)({ workspaceId, workspaceFeature: 'oidcSso' }) + +export const canWorkspaceUseRegions = + (deps: { getWorkspacePlan: GetWorkspacePlan }): WorkspaceFeatureAccessFunction => + async ({ workspaceId }) => + canWorkspaceAccessFeatureFactory(deps)({ + workspaceId, + workspaceFeature: 'workspaceDataRegionSpecificity' + }) + +export const canWorkspaceUseDomainBasedSecurityPolicies = + (deps: { getWorkspacePlan: GetWorkspacePlan }): WorkspaceFeatureAccessFunction => + async ({ workspaceId }) => + canWorkspaceAccessFeatureFactory(deps)({ + workspaceId, + workspaceFeature: 'domainBasedSecurityPolicies' + }) diff --git a/packages/server/modules/gatekeeper/tests/unit/featureAuthorization.spec.ts b/packages/server/modules/gatekeeper/tests/unit/featureAuthorization.spec.ts new file mode 100644 index 000000000..6a09574e9 --- /dev/null +++ b/packages/server/modules/gatekeeper/tests/unit/featureAuthorization.spec.ts @@ -0,0 +1,52 @@ +import { WorkspacePlan } from '@/modules/gatekeeper/domain/billing' +import { WorkspacePlanNotFoundError } from '@/modules/gatekeeper/errors/billing' +import { canWorkspaceAccessFeatureFactory } from '@/modules/gatekeeper/services/featureAuthorization' +import { expectToThrow } from '@/test/assertionHelper' +import { expect } from 'chai' +import cryptoRandomString from 'crypto-random-string' + +describe('featureAuthorization @gatekeeper', () => { + describe('canWorkspaceAccessFeatureFactory creates a function, that', () => { + it('throws an error if workspace is not on a workspacePlan', async () => { + const canWorkspaceAccessFeature = canWorkspaceAccessFeatureFactory({ + getWorkspacePlan: async () => null + }) + const err = await expectToThrow( + async () => + await canWorkspaceAccessFeature({ + workspaceId: cryptoRandomString({ length: 10 }), + workspaceFeature: 'domainBasedSecurityPolicies' + }) + ) + expect(err.message).to.be.equal(new WorkspacePlanNotFoundError().message) + }) + ;( + [ + ['team', 'expired', 'oidcSso', false], + ['team', 'valid', 'oidcSso', false], + ['team', 'valid', 'workspaceDataRegionSpecificity', false], + ['pro', 'valid', 'workspaceDataRegionSpecificity', false], + ['pro', 'canceled', 'oidcSso', false], + ['pro', 'valid', 'oidcSso', true], + ['business', 'valid', 'workspaceDataRegionSpecificity', true] + ] as const + ).forEach(([plan, status, workspaceFeature, expectedResult]) => { + it(`returns ${expectedResult} for ${plan} @ ${status} for ${workspaceFeature}`, async () => { + const workspaceId = cryptoRandomString({ length: 10 }) + const canWorkspaceAccessFeature = canWorkspaceAccessFeatureFactory({ + getWorkspacePlan: async () => + ({ + name: plan, + status, + workspaceId + } as WorkspacePlan) + }) + const result = await canWorkspaceAccessFeature({ + workspaceId, + workspaceFeature + }) + expect(result).to.equal(expectedResult) + }) + }) + }) +})