feat(gatekeeper): add error log on seat mismatch (#5004)
* feat: added a specific errors on downscale issues
This commit is contained in:
committed by
GitHub
parent
556c2791b3
commit
3e7e11b8a1
@@ -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[] = []
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
+11
-4
@@ -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 = {
|
||||
|
||||
+18
-7
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import Stripe from 'stripe'
|
||||
|
||||
export const buildFakeStripe = (updates: Record<string, unknown> = {}): Stripe => {
|
||||
return {
|
||||
subscriptions: {
|
||||
update: async (subscriptionId: string, params?: unknown) => {
|
||||
updates[subscriptionId] = params
|
||||
}
|
||||
}
|
||||
} as unknown as Stripe
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user