feat(gatekeeper): add error log on seat mismatch (#5004)

* feat: added a specific errors on downscale issues
This commit is contained in:
Daniel Gak Anagrov
2025-07-07 12:28:59 +02:00
committed by GitHub
parent 556c2791b3
commit 3e7e11b8a1
11 changed files with 303 additions and 23 deletions
@@ -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
+7 -3
View File
@@ -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
})
}
@@ -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 = {
@@ -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()