import { SubscriptionData, SubscriptionDataInput, WorkspacePlan, WorkspaceSubscription } from '@/modules/gatekeeper/domain/billing' import { WorkspacePlanMismatchError, WorkspacePlanNotFoundError, WorkspaceSubscriptionNotFoundError } from '@/modules/gatekeeper/errors/billing' 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 { 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', () => { 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 () => createTestWorkspaceSubscription({ subscriptionData }), 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 () => createTestWorkspaceSubscription({ subscriptionData, 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 = createTestWorkspaceSubscription({ subscriptionData, 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 = createTestWorkspaceSubscription({ subscriptionData, 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 = createTestWorkspaceSubscription({ subscriptionData, workspaceId }) await handleSubscriptionUpdateFactory({ getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription, getWorkspacePlan: async () => ({ name: 'team', workspaceId, status: 'trial' }), upsertWorkspaceSubscription: async () => { expect.fail() }, upsertPaidWorkspacePlan: async () => { expect.fail() } })({ subscriptionData }) }) }) }) 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' }) }) }) })