diff --git a/packages/server/modules/gatekeeper/clients/stripe.ts b/packages/server/modules/gatekeeper/clients/stripe.ts index 5438a1325..a799f06c6 100644 --- a/packages/server/modules/gatekeeper/clients/stripe.ts +++ b/packages/server/modules/gatekeeper/clients/stripe.ts @@ -41,7 +41,7 @@ export const createCustomerPortalUrlFactory = return session.url } -export const getSubscriptionDataFactory = +export const getStripeSubscriptionDataFactory = ({ stripe }: // getWorkspacePlanPrice @@ -92,9 +92,15 @@ 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 }): ReconcileSubscriptionData => + ({ + stripe, + getStripeSubscriptionData + }: { + stripe: Stripe + getStripeSubscriptionData: GetSubscriptionData + }): ReconcileSubscriptionData => async ({ subscriptionData, prorationBehavior }) => { - const existingSubscriptionState = await getSubscriptionDataFactory({ stripe })({ + const existingSubscriptionState = await getStripeSubscriptionData({ subscriptionId: subscriptionData.subscriptionId }) const items: Stripe.SubscriptionUpdateParams.Item[] = [] diff --git a/packages/server/modules/gatekeeper/errors/billing.ts b/packages/server/modules/gatekeeper/errors/billing.ts index 4be2b0367..1027cf192 100644 --- a/packages/server/modules/gatekeeper/errors/billing.ts +++ b/packages/server/modules/gatekeeper/errors/billing.ts @@ -77,3 +77,9 @@ export class UnsupportedWorkspacePlanError extends BaseError { static code = 'UNSUPPORTED_WORKSPACE_PLAN_ERROR' static statusCode = 400 } + +export class SubscriptionStateError extends BaseError { + static defaultMessage = 'Subscription has an unexpected state' + static code = 'SUBSCRIPTION_STATE_ERROR' + static statusCode = 500 +} diff --git a/packages/server/modules/gatekeeper/events/eventListener.ts b/packages/server/modules/gatekeeper/events/eventListener.ts index 172134118..87e92e0a9 100644 --- a/packages/server/modules/gatekeeper/events/eventListener.ts +++ b/packages/server/modules/gatekeeper/events/eventListener.ts @@ -1,4 +1,7 @@ -import { reconcileWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/clients/stripe' +import { + getStripeSubscriptionDataFactory, + reconcileWorkspaceSubscriptionFactory +} from '@/modules/gatekeeper/clients/stripe' import { getWorkspacePlanFactory, getWorkspaceSubscriptionFactory, @@ -28,7 +31,8 @@ export const initializeEventListenersFactory = getWorkspacePlanPriceId, getWorkspacePlanProductId, reconcileSubscriptionData: reconcileWorkspaceSubscriptionFactory({ - stripe + stripe, + getStripeSubscriptionData: getStripeSubscriptionDataFactory({ stripe }) }), upsertWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({ db }), countSeatsByTypeInWorkspace: countSeatsByTypeInWorkspaceFactory({ db }) diff --git a/packages/server/modules/gatekeeper/graph/resolvers/index.ts b/packages/server/modules/gatekeeper/graph/resolvers/index.ts index 92e9349e9..efc9dd70b 100644 --- a/packages/server/modules/gatekeeper/graph/resolvers/index.ts +++ b/packages/server/modules/gatekeeper/graph/resolvers/index.ts @@ -17,6 +17,7 @@ import { db } from '@/db/knex' import { createCustomerPortalUrlFactory, getRecurringPricesFactory, + getStripeSubscriptionDataFactory, reconcileWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/clients/stripe' import { @@ -458,7 +459,8 @@ export = FF_GATEKEEPER_MODULE_ENABLED const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({ getWorkspacePlan: getWorkspacePlanFactory({ db }), reconcileSubscriptionData: reconcileWorkspaceSubscriptionFactory({ - stripe + stripe, + getStripeSubscriptionData: getStripeSubscriptionDataFactory({ stripe }) }), countSeatsByTypeInWorkspace: countSeatsByTypeInWorkspaceFactory({ db diff --git a/packages/server/modules/gatekeeper/index.ts b/packages/server/modules/gatekeeper/index.ts index 4ae390414..9b7cd22e5 100644 --- a/packages/server/modules/gatekeeper/index.ts +++ b/packages/server/modules/gatekeeper/index.ts @@ -25,7 +25,7 @@ import { upsertWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/repositories/billing' import { - getSubscriptionDataFactory, + getStripeSubscriptionDataFactory, reconcileWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/clients/stripe' import { ScheduleExecution } from '@/modules/core/domain/scheduledTasks/operations' @@ -53,17 +53,21 @@ const scheduleWorkspaceSubscriptionDownscale = ({ scheduleExecution: ScheduleExecution }) => { const stripe = getStripeClient() + const getStripeSubscriptionData = getStripeSubscriptionDataFactory({ stripe }) const manageSubscriptionDownscale = manageSubscriptionDownscaleFactory({ downscaleWorkspaceSubscription: downscaleWorkspaceSubscriptionFactory({ countSeatsByTypeInWorkspace: countSeatsByTypeInWorkspaceFactory({ db }), getWorkspacePlan: getWorkspacePlanFactory({ db }), - reconcileSubscriptionData: reconcileWorkspaceSubscriptionFactory({ stripe }), + reconcileSubscriptionData: reconcileWorkspaceSubscriptionFactory({ + stripe, + getStripeSubscriptionData + }), getWorkspacePlanProductId }), getWorkspaceSubscriptions: getWorkspaceSubscriptionsPastBillingCycleEndFactory({ db }), - getSubscriptionData: getSubscriptionDataFactory({ stripe }), + getStripeSubscriptionData, updateWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({ db }) }) diff --git a/packages/server/modules/gatekeeper/rest/billing.ts b/packages/server/modules/gatekeeper/rest/billing.ts index cf4105697..5cc57e3cd 100644 --- a/packages/server/modules/gatekeeper/rest/billing.ts +++ b/packages/server/modules/gatekeeper/rest/billing.ts @@ -5,7 +5,7 @@ import { getStripeEndpointSigningKey } from '@/modules/shared/helpers/envHelper' import { db } from '@/db/knex' import { completeCheckoutSessionFactory } from '@/modules/gatekeeper/services/checkout' import { - getSubscriptionDataFactory, + getStripeSubscriptionDataFactory, parseSubscriptionData } from '@/modules/gatekeeper/clients/stripe' import { @@ -132,7 +132,7 @@ export const getBillingRouter = (): Router => { }), getWorkspacePlan: getWorkspacePlanFactory({ db }), getWorkspaceSubscription: getWorkspaceSubscriptionFactory({ db }), - getSubscriptionData: getSubscriptionDataFactory({ + getSubscriptionData: getStripeSubscriptionDataFactory({ stripe }), emitEvent: emit @@ -256,7 +256,7 @@ const getSubscriptionFromEventFactory = return null } if (typeof subscription === 'string') { - return await getSubscriptionDataFactory({ stripe })({ + return await getStripeSubscriptionDataFactory({ stripe })({ subscriptionId: subscription }) } diff --git a/packages/server/modules/gatekeeper/services/subscriptions/manageSubscriptionDownscale.ts b/packages/server/modules/gatekeeper/services/subscriptions/manageSubscriptionDownscale.ts index e427c44c7..7c1dd5f2a 100644 --- a/packages/server/modules/gatekeeper/services/subscriptions/manageSubscriptionDownscale.ts +++ b/packages/server/modules/gatekeeper/services/subscriptions/manageSubscriptionDownscale.ts @@ -65,6 +65,7 @@ export const downscaleWorkspaceSubscriptionFactory = }) const subscriptionData = cloneDeep(workspaceSubscription.subscriptionData) + mutateSubscriptionDataWithNewValidSeatNumbers({ seatCount: editorsCount, workspacePlan: workspacePlan.name, @@ -86,12 +87,12 @@ export const manageSubscriptionDownscaleFactory = getWorkspaceSubscriptions, downscaleWorkspaceSubscription, updateWorkspaceSubscription, - getSubscriptionData + getStripeSubscriptionData }: { getWorkspaceSubscriptions: GetWorkspaceSubscriptions downscaleWorkspaceSubscription: DownscaleWorkspaceSubscription updateWorkspaceSubscription: UpsertWorkspaceSubscription - getSubscriptionData: GetSubscriptionData + getStripeSubscriptionData: GetSubscriptionData }) => async (context: { logger: Logger }) => { const { logger } = context @@ -110,9 +111,15 @@ export const manageSubscriptionDownscaleFactory = log.info('Did not need to downscale the workspace subscription') } } catch (err) { - log.error({ err }, 'Failed to downscale workspace subscription') + log.error( + { + err, + workspaceId: workspaceSubscription.workspaceId + }, + 'Failed to downscale workspace subscription' + ) } - const subscriptionData = await getSubscriptionData( + const subscriptionData = await getStripeSubscriptionData( workspaceSubscription.subscriptionData ) const updatedWorkspaceSubscription = { diff --git a/packages/server/modules/gatekeeper/services/subscriptions/mutateSubscriptionDataWithNewValidSeatNumbers.ts b/packages/server/modules/gatekeeper/services/subscriptions/mutateSubscriptionDataWithNewValidSeatNumbers.ts index b8c13fddb..a92039a88 100644 --- a/packages/server/modules/gatekeeper/services/subscriptions/mutateSubscriptionDataWithNewValidSeatNumbers.ts +++ b/packages/server/modules/gatekeeper/services/subscriptions/mutateSubscriptionDataWithNewValidSeatNumbers.ts @@ -4,6 +4,7 @@ import { } from '@/modules/gatekeeper/domain/billing' import { WorkspacePricingProducts } from '@/modules/gatekeeperCore/domain/billing' import { LogicError } from '@/modules/shared/errors' +import { SubscriptionStateError } from '@/modules/gatekeeper/errors/billing' export const mutateSubscriptionDataWithNewValidSeatNumbers = ({ seatCount, @@ -20,15 +21,25 @@ export const mutateSubscriptionDataWithNewValidSeatNumbers = ({ const product = subscriptionData.products.find( (product) => product.productId === productId ) - if (seatCount < 0) throw new LogicError('Invalid seat count, cannot be negative') - if (seatCount === 0 && product === undefined) return - if (seatCount === 0 && product !== undefined) { + if (product === undefined && seatCount === 0) return + if (product === undefined) { + throw new LogicError('Product not found at mutation') + } + + if (seatCount < 0) { + throw new LogicError('Invalid seat count, cannot be negative') + } + + if (product.quantity < seatCount) { + throw new SubscriptionStateError('Subscription missing an upscale') + } + + if (seatCount === 0) { const prodIndex = subscriptionData.products.indexOf(product) subscriptionData.products.splice(prodIndex, 1) - } else if (product !== undefined && product.quantity >= seatCount) { - product.quantity = seatCount - } else { - throw new LogicError('Invalid subscription state') + return } + + product.quantity = seatCount } diff --git a/packages/server/modules/gatekeeper/tests/helpers/stripe.ts b/packages/server/modules/gatekeeper/tests/helpers/stripe.ts new file mode 100644 index 000000000..5b214a94b --- /dev/null +++ b/packages/server/modules/gatekeeper/tests/helpers/stripe.ts @@ -0,0 +1,11 @@ +import Stripe from 'stripe' + +export const buildFakeStripe = (updates: Record = {}): Stripe => { + return { + subscriptions: { + update: async (subscriptionId: string, params?: unknown) => { + updates[subscriptionId] = params + } + } + } as unknown as Stripe +} diff --git a/packages/server/modules/gatekeeper/tests/unit/stripe.spec.ts b/packages/server/modules/gatekeeper/tests/unit/stripe.spec.ts new file mode 100644 index 000000000..3fb59a018 --- /dev/null +++ b/packages/server/modules/gatekeeper/tests/unit/stripe.spec.ts @@ -0,0 +1,141 @@ +import { buildFakeStripe } from '@/modules/gatekeeper/tests/helpers/stripe' +import cryptoRandomString from 'crypto-random-string' +import { + buildTestSubscriptionData, + buildTestSubscriptionProduct +} from '@/modules/gatekeeper/tests/helpers/workspacePlan' +import { reconcileWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/clients/stripe' +import { expect } from 'chai' + +describe('Stripe integration', () => { + describe('Reconciliation', () => { + it('does not send any delete or create anything in Stripe when existing subscription equals to the new one', async () => { + const updates = {} + const subscriptionId = cryptoRandomString({ length: 10 }) + const subscriptionItemId = cryptoRandomString({ length: 10 }) + const fakeStripe = buildFakeStripe(updates) + const subscriptionData = buildTestSubscriptionData({ + subscriptionId, + products: [ + buildTestSubscriptionProduct({ + subscriptionItemId, + quantity: 2 + }) + ] + }) + const reconcileWorkspaceSubscription = reconcileWorkspaceSubscriptionFactory({ + stripe: fakeStripe, + getStripeSubscriptionData: async () => subscriptionData + }) + + await reconcileWorkspaceSubscription({ + subscriptionData, + prorationBehavior: 'none' + }) + + expect(updates).to.be.deep.equal({ + [subscriptionId]: { + items: [{ quantity: 2, id: subscriptionItemId }], + // eslint-disable-next-line camelcase + proration_behavior: 'none' + } + }) + }) + + it('deletes the current products and adds only the needed when stripe has more products than we provided', async () => { + const updates = {} + const subscriptionId = cryptoRandomString({ length: 10 }) + const priceId = cryptoRandomString({ length: 10 }) + const subscriptionItemId = cryptoRandomString({ length: 10 }) + const fakeStripe = buildFakeStripe(updates) + const subscriptionData = buildTestSubscriptionData({ + subscriptionId, + products: [ + buildTestSubscriptionProduct({ + priceId, + subscriptionItemId, + quantity: 1 + }) + ] + }) + const reconcileWorkspaceSubscription = reconcileWorkspaceSubscriptionFactory({ + stripe: fakeStripe, + getStripeSubscriptionData: async () => + buildTestSubscriptionData({ + subscriptionId, + products: [ + buildTestSubscriptionProduct({ + priceId, + subscriptionItemId, + quantity: 2 + }) + ] + }) + }) + + await reconcileWorkspaceSubscription({ + subscriptionData, + prorationBehavior: 'none' + }) + + expect(updates).to.be.deep.equal({ + [subscriptionId]: { + items: [ + { quantity: 1, price: priceId }, + { deleted: true, id: subscriptionItemId } + ], + // eslint-disable-next-line camelcase + proration_behavior: 'none' + } + }) + }) + + it('deletes the current products and ads new ones when stripe has less products than we provided', async () => { + const updates = {} + const subscriptionId = cryptoRandomString({ length: 10 }) + const priceId = cryptoRandomString({ length: 10 }) + const subscriptionItemId = cryptoRandomString({ length: 10 }) + const fakeStripe = buildFakeStripe(updates) + const subscriptionData = buildTestSubscriptionData({ + subscriptionId, + products: [ + buildTestSubscriptionProduct({ + priceId, + subscriptionItemId, + quantity: 3 + }) + ] + }) + const reconcileWorkspaceSubscription = reconcileWorkspaceSubscriptionFactory({ + stripe: fakeStripe, + getStripeSubscriptionData: async () => + buildTestSubscriptionData({ + subscriptionId, + products: [ + buildTestSubscriptionProduct({ + priceId, + subscriptionItemId, + quantity: 2 + }) + ] + }) + }) + + await reconcileWorkspaceSubscription({ + subscriptionData, + prorationBehavior: 'none' + }) + + expect(updates).to.be.deep.equal({ + [subscriptionId]: { + items: [ + { quantity: 3, price: priceId }, + { deleted: true, id: subscriptionItemId } + ], + // eslint-disable-next-line camelcase + proration_behavior: 'none' + } + }) + }) + }) +}) diff --git a/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts b/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts index 226dad693..3534f42a6 100644 --- a/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts +++ b/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts @@ -30,6 +30,7 @@ import { omit } from 'lodash' import { upgradeWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/services/subscriptions/upgradeWorkspaceSubscription' import { EventBusEmit } from '@/modules/shared/services/eventBus' import { testLogger } from '@/observability/logging' +import { mutateSubscriptionDataWithNewValidSeatNumbers } from '@/modules/gatekeeper/services/subscriptions/mutateSubscriptionDataWithNewValidSeatNumbers' describe('subscriptions @gatekeeper', () => { describe('handleSubscriptionUpdateFactory creates a function, that', () => { @@ -811,6 +812,93 @@ describe('subscriptions @gatekeeper', () => { }) }) + describe('mutateSubscriptionDataWithNewValidSeats', () => { + it('can totally downscale the subscription', () => { + const desiredSeatCount = 0 + const productId = cryptoRandomString({ length: 10 }) + const priceId = cryptoRandomString({ length: 10 }) + const subscriptionItemId = cryptoRandomString({ length: 10 }) + const currentQuantity = 2 + const subscriptionData = createTestSubscriptionData({ + products: [ + { priceId, productId, quantity: currentQuantity, subscriptionItemId } + ] + }) + + mutateSubscriptionDataWithNewValidSeatNumbers({ + seatCount: desiredSeatCount, + workspacePlan: PaidWorkspacePlans.Team, + getWorkspacePlanProductId: () => productId, + subscriptionData + }) + + expect(subscriptionData.products).to.has.lengthOf(0) + }) + + it('mutates the quantity when the count is less than the one given', () => { + const desiredSeatCount = 5 + const productId = cryptoRandomString({ length: 10 }) + const priceId = cryptoRandomString({ length: 10 }) + const subscriptionItemId = cryptoRandomString({ length: 10 }) + const currentQuantity = 10 + const subscriptionData = createTestSubscriptionData({ + products: [ + { priceId, productId, quantity: currentQuantity, subscriptionItemId } + ] + }) + + mutateSubscriptionDataWithNewValidSeatNumbers({ + seatCount: desiredSeatCount, + workspacePlan: PaidWorkspacePlans.Team, + getWorkspacePlanProductId: () => productId, + subscriptionData + }) + + expect(subscriptionData.products[0].quantity).to.be.equal(desiredSeatCount) + }) + + it('throws an exception to notify that instead of downscale, a subscription is broken and an upscale is required', () => { + const desiredSeatCount = 10 + const currentQuantity = 5 + const productId = cryptoRandomString({ length: 10 }) + const priceId = cryptoRandomString({ length: 10 }) + const subscriptionItemId = cryptoRandomString({ length: 10 }) + const subscriptionData = createTestSubscriptionData({ + products: [ + { priceId, productId, quantity: currentQuantity, subscriptionItemId } + ] + }) + + const mutation = () => + mutateSubscriptionDataWithNewValidSeatNumbers({ + seatCount: desiredSeatCount, + workspacePlan: PaidWorkspacePlans.Team, + getWorkspacePlanProductId: () => productId, + subscriptionData + }) + + expect(mutation).to.throw('Subscription missing an upscale') + }) + + it('throws an exception when the subscription is malformed', () => { + const desiredSeatCount = 10 + const productId = cryptoRandomString({ length: 10 }) + const subscriptionData = createTestSubscriptionData({ + products: [] + }) + + const mutation = () => + mutateSubscriptionDataWithNewValidSeatNumbers({ + seatCount: desiredSeatCount, + workspacePlan: PaidWorkspacePlans.Team, + getWorkspacePlanProductId: () => productId, + subscriptionData + }) + + expect(mutation).to.throw('Product not found at mutation') + }) + }) + describe('downscaleWorkspaceSubscriptionFactory creates a function, that', () => { it('throws an error if the workspace has no plan attached to it', async () => { const subscriptionData = createTestSubscriptionData()