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/**"