feat(activity): added user info to checkout_subscription and subscription upgrade (#4967)

* feat: added userId to checkout_subscription
* feat: add update intent to subscription
This commit is contained in:
Daniel Gak Anagrov
2025-06-24 15:34:26 +02:00
committed by GitHub
parent 2d8cab7772
commit 51d6a8dd67
18 changed files with 416 additions and 178 deletions
@@ -23,6 +23,7 @@ export const createCheckoutSessionFactory =
billingInterval,
workspaceSlug,
workspaceId,
userId,
isCreateFlow,
currency
}) => {
@@ -53,6 +54,7 @@ export const createCheckoutSessionFactory =
billingInterval,
workspacePlan,
workspaceId,
userId,
currency,
createdAt: new Date(),
updatedAt: new Date(),
@@ -60,6 +60,7 @@ export type SessionPaymentStatus = 'paid' | 'unpaid'
export type CheckoutSession = SessionInput & {
url: string
workspaceId: string
userId: string
workspacePlan: PaidWorkspacePlans
paymentStatus: SessionPaymentStatus
billingInterval: WorkspacePlanBillingIntervals
@@ -91,6 +92,7 @@ export type UpdateCheckoutSessionStatus = (args: {
export type CreateCheckoutSession = (args: {
workspaceId: string
userId: string
workspaceSlug: string
editorsCount: number
workspacePlan: PaidWorkspacePlans
@@ -106,17 +108,34 @@ export type WorkspaceSubscription = {
currentBillingCycleEnd: Date
billingInterval: WorkspacePlanBillingIntervals
currency: Currency
updateIntent: SubscriptionUpdateIntent | null
subscriptionData: SubscriptionData
}
export type SubscriptionUpdateIntent = {
userId: string
products: SubscriptionIntentProduct[]
planName: PaidWorkspacePlans
} & Pick<
WorkspaceSubscription,
// status is not needed cause its always provided by stripe
'currentBillingCycleEnd' | 'currency' | 'billingInterval' | 'updatedAt'
>
const subscriptionProduct = z.object({
productId: z.string(),
subscriptionItemId: z.string(),
subscriptionItemId: z.string(), // does not exist until billing is called with success
priceId: z.string(),
quantity: z.number()
})
export type SubscriptionProduct = z.infer<typeof subscriptionProduct>
type SubscriptionIntentProduct = Pick<
SubscriptionProduct,
'productId' | 'priceId' | 'quantity'
>
export const SubscriptionData = z.object({
subscriptionId: z.string().min(1),
customerId: z.string().min(1),
@@ -31,7 +31,6 @@ import {
getWorkspacePlanFactory,
getWorkspaceSubscriptionFactory,
saveCheckoutSessionFactory,
upsertPaidWorkspacePlanFactory,
upsertWorkspaceSubscriptionFactory
} from '@/modules/gatekeeper/repositories/billing'
import { canWorkspaceAccessFeatureFactory } from '@/modules/gatekeeper/services/featureAuthorization'
@@ -41,7 +40,7 @@ import {
WorkspaceSeatType
} from '@/modules/gatekeeper/domain/billing'
import { WorkspacePaymentMethod } from '@/test/graphql/generated/graphql'
import { LogicError } from '@/modules/shared/errors'
import { LogicError, UnauthorizedError } from '@/modules/shared/errors'
import { getWorkspacePlanProductPricesFactory } from '@/modules/gatekeeper/services/prices'
import { extendLoggerComponent } from '@/observability/logging'
import { createCheckoutSessionFactory } from '@/modules/gatekeeper/clients/checkout/createCheckoutSession'
@@ -395,12 +394,15 @@ export = FF_GATEKEEPER_MODULE_ENABLED
const { workspaceId, workspacePlan, billingInterval, isCreateFlow } =
args.input
logger = logger.child({ workspaceId, workspacePlan })
const userId = ctx.userId
if (!userId) throw new UnauthorizedError()
const workspace = await getWorkspaceFactory({ db })({ workspaceId })
if (!workspace) throw new WorkspaceNotFoundError()
await authorizeResolver(
ctx.userId,
userId,
workspaceId,
Roles.Workspace.Admin,
ctx.resourceAccessRules
@@ -425,6 +427,7 @@ export = FF_GATEKEEPER_MODULE_ENABLED
await startCheckoutSession({
workspacePlan,
workspaceId,
userId,
workspaceSlug: workspace.slug,
isCreateFlow: isCreateFlow || false,
billingInterval,
@@ -442,8 +445,10 @@ export = FF_GATEKEEPER_MODULE_ENABLED
const { workspaceId, workspacePlan, billingInterval } = args.input
logger = logger.child({ workspaceId, workspacePlan })
const userId = ctx.userId
if (!userId) throw new UnauthorizedError()
await authorizeResolver(
ctx.userId,
userId,
workspaceId,
Roles.Workspace.Admin,
ctx.resourceAccessRules
@@ -461,15 +466,14 @@ export = FF_GATEKEEPER_MODULE_ENABLED
getWorkspaceSubscription: getWorkspaceSubscriptionFactory({ db }),
getWorkspacePlanPriceId,
getWorkspacePlanProductId,
upsertWorkspacePlan: upsertPaidWorkspacePlanFactory({ db }),
updateWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({
db
}),
emitEvent: getEventBus().emit
})
})
await withOperationLogging(
async () =>
await upgradeWorkspaceSubscription({
userId,
workspaceId,
targetPlan: workspacePlan, // This should not be casted and the cast will be removed once we will not support old plans anymore
billingInterval
@@ -43,7 +43,8 @@ const WorkspaceSubscriptions = buildTableHelper('workspace_subscriptions', [
'currentBillingCycleEnd',
'billingInterval',
'subscriptionData',
'currency'
'currency',
'updateIntent'
])
const tables = {
@@ -203,7 +203,7 @@ export const getBillingRouter = (): Router => {
getWorkspaceSubscriptionBySubscriptionIdFactory({ db }),
upsertWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({ db }),
emitEvent: getEventBus().emit
})({ subscriptionData: parseSubscriptionData(event.data.object) }),
})({ subscriptionData: parseSubscriptionData(event.data.object), logger }),
{
logger,
operationName: 'handleSubscriptionUpdate',
@@ -226,7 +226,7 @@ export const getBillingRouter = (): Router => {
getWorkspaceSubscriptionBySubscriptionIdFactory({ db }),
upsertWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({ db }),
emitEvent: getEventBus().emit
})({ subscriptionData }),
})({ subscriptionData, logger }),
{
logger,
operationName: 'handleSubscriptionUpdate',
@@ -82,6 +82,7 @@ export const completeCheckoutSessionFactory =
workspaceId: checkoutSession.workspaceId,
billingInterval: checkoutSession.billingInterval,
currency: checkoutSession.currency,
updateIntent: null,
subscriptionData
}
@@ -41,6 +41,7 @@ export const startCheckoutSessionFactory =
}) =>
async ({
workspaceId,
userId,
workspaceSlug,
workspacePlan,
billingInterval,
@@ -48,6 +49,7 @@ export const startCheckoutSessionFactory =
currency
}: {
workspaceId: string
userId: string
workspaceSlug: string
workspacePlan: PaidWorkspacePlans
billingInterval: WorkspacePlanBillingIntervals
@@ -132,6 +134,7 @@ export const startCheckoutSessionFactory =
const checkoutSession = await createCheckoutSession({
workspaceId,
userId,
workspaceSlug,
billingInterval,
workspacePlan,
@@ -27,6 +27,7 @@ import { cloneDeep } from 'lodash'
import { CountSeatsByTypeInWorkspace } from '@/modules/gatekeeper/domain/operations'
import { EventBusEmit } from '@/modules/shared/services/eventBus'
import { GatekeeperEvents } from '@/modules/gatekeeperCore/domain/events'
import { Logger } from '@/observability/logging'
export const handleSubscriptionUpdateFactory =
({
@@ -42,8 +43,14 @@ export const handleSubscriptionUpdateFactory =
upsertWorkspaceSubscription: UpsertWorkspaceSubscription
emitEvent: EventBusEmit
}) =>
async ({ subscriptionData }: { subscriptionData: SubscriptionData }) => {
// we're only handling marking the sub scheduled for cancelation right now
async ({
subscriptionData,
logger
}: {
subscriptionData: SubscriptionData
logger: Logger
}) => {
// we're only handling marking the sub scheduled for cancellation right now
const subscription = await getWorkspaceSubscriptionBySubscriptionId({
subscriptionId: subscriptionData.subscriptionId
})
@@ -79,52 +86,138 @@ export const handleSubscriptionUpdateFactory =
status = 'canceled'
}
if (status) {
switch (workspacePlan.name) {
case WorkspacePlans.Team:
case WorkspacePlans.TeamUnlimited:
case WorkspacePlans.Pro:
case WorkspacePlans.ProUnlimited:
break
case WorkspacePlans.Unlimited:
case WorkspacePlans.Academia:
case WorkspacePlans.ProUnlimitedInvoiced:
case WorkspacePlans.TeamUnlimitedInvoiced:
case WorkspacePlans.Free:
case WorkspacePlans.Enterprise:
throw new WorkspacePlanMismatchError()
default:
throwUncoveredError(workspacePlan)
}
if (!status) {
logger.info({ workspaceId: subscription.workspaceId }, 'Nothing to update')
return
}
const newWorkspacePlan = { ...workspacePlan, status }
await upsertPaidWorkspacePlan({
workspacePlan: newWorkspacePlan
})
// if there is a status in the sub, we recognize, we need to update our state
await upsertWorkspaceSubscription({
workspaceSubscription: {
...subscription,
updatedAt: new Date(),
subscriptionData
}
})
switch (workspacePlan.name) {
case WorkspacePlans.Team:
case WorkspacePlans.TeamUnlimited:
case WorkspacePlans.Pro:
case WorkspacePlans.ProUnlimited:
break
case WorkspacePlans.Unlimited:
case WorkspacePlans.Academia:
case WorkspacePlans.ProUnlimitedInvoiced:
case WorkspacePlans.TeamUnlimitedInvoiced:
case WorkspacePlans.Free:
case WorkspacePlans.Enterprise:
throw new WorkspacePlanMismatchError()
default:
throwUncoveredError(workspacePlan)
}
await emitEvent({
eventName: GatekeeperEvents.WorkspaceSubscriptionUpdated,
payload: {
workspacePlan: newWorkspacePlan,
subscription: {
totalEditorSeats: calculateSubscriptionSeats({ subscriptionData })
const updateIntent = subscription.updateIntent
let planName
let billingInterval
let currentBillingCycleEnd
let currency
let updatedAt
if (updateIntent) {
// this is the branch where a user intents to upgrade his subscription
// if stripe comes back with a status, and we have a update intent in the subscription
// we're assuming that the target that the user wants to upgrade was written in the update intent
planName = updateIntent.planName
updatedAt = updateIntent.updatedAt
currency = updateIntent.currency
billingInterval = updateIntent.billingInterval
currentBillingCycleEnd = updateIntent.currentBillingCycleEnd
const productsAreEquivalent = (
a: Array<{ priceId: string; quantity: number }>,
b: Array<{ priceId: string; quantity: number }>
) =>
a.every((item) => {
return !!b.find(
(bi) => bi.priceId === item.priceId && bi.quantity === item.quantity
)
})
if (!productsAreEquivalent(updateIntent.products, subscriptionData.products)) {
logger.error(
{
event: subscriptionData.products,
target: updateIntent.products,
workspaceId: subscription.workspaceId,
targetPlanName: planName,
planName: workspacePlan.name
},
previousSubscription: {
totalEditorSeats: calculateSubscriptionSeats({
subscriptionData: subscription.subscriptionData
})
}
'Fatal: Stripe product ID mismatch with subscription update intent'
)
}
} else {
planName = workspacePlan.name
billingInterval = subscription.billingInterval
currentBillingCycleEnd = subscription.currentBillingCycleEnd
currency = subscription.currency
updatedAt = new Date()
// Stripe can have many cases were we receive an event
// - subscription cancellation schedules
// - subscription cancellations
// - payment failures
// - duplicated events
// - manual changes in the dashboard
// - ...
// at the moment, we are assuming this new status and update the status as given by stripe
// take into account that manual subscription updates in stripe dashboard can lead into
// errors, as changing quantity in the products may work, but changing product ids wont update
// the workspace plan and will result in errors
}
const newWorkspacePlan = {
...workspacePlan,
status,
name: planName,
updatedAt
}
const newSubscription = {
...subscription,
currency,
currentBillingCycleEnd,
billingInterval,
updateIntent: null,
updatedAt,
subscriptionData
}
await upsertPaidWorkspacePlan({
workspacePlan: newWorkspacePlan
})
await upsertWorkspaceSubscription({
workspaceSubscription: newSubscription
})
if (
workspacePlan.name !== newWorkspacePlan.name ||
workspacePlan.status !== newWorkspacePlan.status
) {
await emitEvent({
eventName: GatekeeperEvents.WorkspacePlanUpdated,
payload: {
previousPlan: workspacePlan,
workspacePlan: newWorkspacePlan
}
})
}
await emitEvent({
eventName: GatekeeperEvents.WorkspaceSubscriptionUpdated,
payload: {
workspacePlan: newWorkspacePlan,
subscription: {
totalEditorSeats: calculateSubscriptionSeats({ subscriptionData })
},
previousSubscription: {
totalEditorSeats: calculateSubscriptionSeats({
subscriptionData: subscription.subscriptionData
})
}
}
})
}
export const addWorkspaceSubscriptionSeatIfNeededFactory =
@@ -5,7 +5,6 @@ import {
GetWorkspaceSubscription,
ReconcileSubscriptionData,
SubscriptionDataInput,
UpsertPaidWorkspacePlan,
UpsertWorkspaceSubscription,
WorkspaceSeatType
} from '@/modules/gatekeeper/domain/billing'
@@ -23,7 +22,6 @@ import { isPaidPlanType } from '@/modules/gatekeeper/helpers/plans'
import { calculateNewBillingCycleEnd } from '@/modules/gatekeeper/services/subscriptions/calculateNewBillingCycleEnd'
import { mutateSubscriptionDataWithNewValidSeatNumbers } from '@/modules/gatekeeper/services/subscriptions/mutateSubscriptionDataWithNewValidSeatNumbers'
import { isUpgradeWorkspacePlanValid } from '@/modules/gatekeeper/services/upgrades'
import { EventBusEmit } from '@/modules/shared/services/eventBus'
import {
PaidWorkspacePlans,
throwUncoveredError,
@@ -40,9 +38,7 @@ export const upgradeWorkspaceSubscriptionFactory =
getWorkspaceSubscription,
reconcileSubscriptionData,
updateWorkspaceSubscription,
countSeatsByTypeInWorkspace,
upsertWorkspacePlan,
emitEvent
countSeatsByTypeInWorkspace
}: {
getWorkspacePlan: GetWorkspacePlan
getWorkspacePlanProductId: GetWorkspacePlanProductId
@@ -51,14 +47,14 @@ export const upgradeWorkspaceSubscriptionFactory =
reconcileSubscriptionData: ReconcileSubscriptionData
updateWorkspaceSubscription: UpsertWorkspaceSubscription
countSeatsByTypeInWorkspace: CountSeatsByTypeInWorkspace
upsertWorkspacePlan: UpsertPaidWorkspacePlan
emitEvent: EventBusEmit
}) =>
async ({
userId,
workspaceId,
targetPlan,
billingInterval
}: {
userId: string
workspaceId: string
targetPlan: PaidWorkspacePlans
billingInterval: WorkspacePlanBillingIntervals
@@ -139,9 +135,7 @@ export const upgradeWorkspaceSubscriptionFactory =
default:
throwUncoveredError(billingInterval)
}
// must update the billing interval to the new one
workspaceSubscription.billingInterval = billingInterval
workspaceSubscription.currentBillingCycleEnd = calculateNewBillingCycleEnd({
const currentBillingCycleEnd = calculateNewBillingCycleEnd({
workspaceSubscription
})
@@ -160,8 +154,6 @@ export const upgradeWorkspaceSubscriptionFactory =
type: WorkspaceSeatType.Editor
})
workspaceSubscription.updatedAt = new Date()
// set current plan seat count to 0
mutateSubscriptionDataWithNewValidSeatNumbers({
seatCount: 0,
@@ -170,43 +162,31 @@ export const upgradeWorkspaceSubscriptionFactory =
workspacePlan: workspacePlan.name
})
// set target plan seat count to current seat count
subscriptionData.products.push({
// set target plan and subscription
const newProduct = {
quantity: editorsCount,
productId: getWorkspacePlanProductId({ workspacePlan: targetPlan }),
priceId: getWorkspacePlanPriceId({
workspacePlan: targetPlan,
billingInterval,
currency: workspaceSubscription.currency
}),
subscriptionItemId: undefined
})
})
}
workspaceSubscription.updateIntent = {
userId,
planName: targetPlan,
billingInterval,
currentBillingCycleEnd,
currency: workspaceSubscription.currency,
updatedAt: new Date(),
products: [newProduct]
}
await updateWorkspaceSubscription({ workspaceSubscription })
subscriptionData.products.push(newProduct)
await reconcileSubscriptionData({
subscriptionData,
prorationBehavior: 'always_invoice'
})
await upsertWorkspacePlan({
workspacePlan: {
status: workspacePlan.status,
workspaceId,
name: targetPlan,
createdAt: new Date(),
updatedAt: new Date()
}
})
await updateWorkspaceSubscription({ workspaceSubscription })
await emitEvent({
eventName: 'gatekeeper.workspace-plan-updated',
payload: {
workspacePlan: {
workspaceId,
status: workspacePlan.status,
name: targetPlan
},
...(workspacePlan && {
previousPlan: { name: workspacePlan.name }
})
}
})
}
@@ -37,6 +37,7 @@ export const createTestWorkspaceSubscription = (
updatedAt: new Date(),
currentBillingCycleEnd: new Date(),
subscriptionData: createTestSubscriptionData(),
updateIntent: null,
currency: 'usd',
workspaceId: cryptoRandomString({ length: 10 })
}
@@ -31,6 +31,7 @@ export const buildTestWorkspaceSubscription = (
updatedAt: new Date(),
currentBillingCycleEnd: new Date(),
billingInterval: 'monthly',
updateIntent: {},
currency: 'usd',
subscriptionData: buildTestSubscriptionData()
},
@@ -255,6 +255,7 @@ describe('billing repositories @gatekeeper', () => {
expect(storedSession).to.be.null
const checkoutSession = {
id: cryptoRandomString({ length: 10 }),
userId: cryptoRandomString({ length: 10 }),
billingInterval: 'monthly',
createdAt: new Date(),
paymentStatus: 'unpaid',
@@ -279,6 +280,7 @@ describe('billing repositories @gatekeeper', () => {
const workspaceId = workspace.id
const checkoutSession = {
id: cryptoRandomString({ length: 10 }),
userId: cryptoRandomString({ length: 10 }),
billingInterval: 'monthly',
createdAt: new Date(),
paymentStatus: 'unpaid',
@@ -312,6 +314,7 @@ describe('billing repositories @gatekeeper', () => {
const workspaceId = workspace.id
const checkoutSession = {
id: cryptoRandomString({ length: 10 }),
userId: cryptoRandomString({ length: 10 }),
billingInterval: 'monthly',
createdAt: new Date(),
paymentStatus: 'unpaid',
@@ -354,6 +357,7 @@ describe('billing repositories @gatekeeper', () => {
const workspaceId = workspace.id
const checkoutSession = {
id: cryptoRandomString({ length: 10 }),
userId: cryptoRandomString({ length: 10 }),
billingInterval: 'monthly',
createdAt: new Date(),
paymentStatus: 'unpaid',
@@ -137,6 +137,7 @@ describe('Workspaces Billing', () => {
currentBillingCycleEnd: dayjs().add(1, 'month').toDate(),
currency: 'usd',
billingInterval: 'monthly',
updateIntent: null,
subscriptionData: {
subscriptionId: cryptoRandomString({ length: 10 }),
customerId: cryptoRandomString({ length: 10 }),
@@ -187,6 +188,7 @@ describe('Workspaces Billing', () => {
updatedAt: new Date(),
currentBillingCycleEnd: dayjs().add(1, 'month').toDate(),
currency: 'usd',
updateIntent: null,
billingInterval: 'monthly',
subscriptionData: {
subscriptionId: cryptoRandomString({ length: 10 }),
@@ -54,12 +54,14 @@ describe('checkout @gatekeeper', () => {
it('throws for already paid checkout sessions', async () => {
const sessionId = cryptoRandomString({ length: 10 })
const subscriptionId = cryptoRandomString({ length: 10 })
const userId = cryptoRandomString({ length: 10 })
const err = await expectToThrow(async () => {
await completeCheckoutSessionFactory({
getCheckoutSession: async () => ({
billingInterval: 'monthly',
id: sessionId,
userId,
paymentStatus: 'paid',
url: 'https://example.com',
workspaceId: cryptoRandomString({ length: 10 }),
@@ -93,10 +95,12 @@ describe('checkout @gatekeeper', () => {
const sessionId = cryptoRandomString({ length: 10 })
const subscriptionId = cryptoRandomString({ length: 10 })
const workspaceId = cryptoRandomString({ length: 10 })
const userId = cryptoRandomString({ length: 10 })
const storedCheckoutSession: CheckoutSession = {
billingInterval,
id: sessionId,
userId,
paymentStatus: 'unpaid',
url: 'https://example.com',
workspaceId,
@@ -199,6 +203,7 @@ describe('checkout @gatekeeper', () => {
describe('startCheckoutSessionFactory creates a function, that', () => {
it('does not allow checkout if workspace plan does not exists', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const userId = cryptoRandomString({ length: 10 })
const err = await expectToThrow(() =>
startCheckoutSessionFactory({
getWorkspacePlan: async () => null,
@@ -219,6 +224,7 @@ describe('checkout @gatekeeper', () => {
}
})({
workspaceId,
userId,
billingInterval: 'monthly',
workspacePlan: 'pro',
workspaceSlug: cryptoRandomString({ length: 10 }),
@@ -230,6 +236,7 @@ describe('checkout @gatekeeper', () => {
})
it('does not allow checkout for paid workspace plans, that is in a valid state', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const userId = cryptoRandomString({ length: 10 })
const err = await expectToThrow(() =>
startCheckoutSessionFactory({
getWorkspacePlan: async () => ({
@@ -256,6 +263,7 @@ describe('checkout @gatekeeper', () => {
}
})({
workspaceId,
userId,
billingInterval: 'monthly',
workspacePlan: 'pro',
workspaceSlug: cryptoRandomString({ length: 10 }),
@@ -267,6 +275,7 @@ describe('checkout @gatekeeper', () => {
})
it('does not allow checkout for workspace plans, that is in a paymentFailed state', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const userId = cryptoRandomString({ length: 10 })
const err = await expectToThrow(() =>
startCheckoutSessionFactory({
getWorkspacePlan: async () => ({
@@ -293,6 +302,7 @@ describe('checkout @gatekeeper', () => {
}
})({
workspaceId,
userId,
billingInterval: 'monthly',
workspacePlan: 'pro',
workspaceSlug: cryptoRandomString({ length: 10 }),
@@ -304,6 +314,7 @@ describe('checkout @gatekeeper', () => {
})
it('does not allow checkout for a workspace, that already has a checkout session', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const userId = cryptoRandomString({ length: 10 })
const err = await expectToThrow(() =>
startCheckoutSessionFactory({
getWorkspacePlan: async () => ({
@@ -319,6 +330,7 @@ describe('checkout @gatekeeper', () => {
paymentStatus: 'unpaid',
url: '',
workspaceId,
userId,
workspacePlan: PaidWorkspacePlans.Team,
currency: 'usd',
createdAt: new Date(),
@@ -338,6 +350,7 @@ describe('checkout @gatekeeper', () => {
expect.fail()
}
})({
userId,
workspaceId,
billingInterval: 'monthly',
workspacePlan: 'team',
@@ -353,11 +366,13 @@ describe('checkout @gatekeeper', () => {
it('creates and stores a checkout for FREE workspaces', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const userId = cryptoRandomString({ length: 10 })
const workspacePlan: PaidWorkspacePlans = 'pro'
const billingInterval: WorkspacePlanBillingIntervals = 'monthly'
const checkoutSession: CheckoutSession = {
id: cryptoRandomString({ length: 10 }),
workspaceId,
userId,
workspacePlan,
url: 'https://example.com',
billingInterval,
@@ -386,6 +401,7 @@ describe('checkout @gatekeeper', () => {
}
})({
workspaceId,
userId,
billingInterval,
workspacePlan,
workspaceSlug: cryptoRandomString({ length: 10 }),
@@ -398,11 +414,13 @@ describe('checkout @gatekeeper', () => {
it('creates and stores a checkout for FREE workspaces even if it has an old unpaid checkout session', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const userId = cryptoRandomString({ length: 10 })
const workspacePlan: PaidWorkspacePlans = 'team'
const billingInterval: WorkspacePlanBillingIntervals = 'monthly'
const checkoutSession: CheckoutSession = {
id: cryptoRandomString({ length: 10 }),
workspaceId,
userId,
workspacePlan,
url: 'https://example.com',
billingInterval,
@@ -420,6 +438,7 @@ describe('checkout @gatekeeper', () => {
currency: 'usd',
url: 'https://example.com',
workspaceId,
userId,
workspacePlan
}
let storedCheckoutSession: CheckoutSession | undefined = undefined
@@ -442,6 +461,7 @@ describe('checkout @gatekeeper', () => {
}
})({
workspaceId,
userId,
billingInterval,
workspacePlan,
workspaceSlug: cryptoRandomString({ length: 10 }),
@@ -455,6 +475,7 @@ describe('checkout @gatekeeper', () => {
it('does not allow checkout for FREE workspaces if there is a paid checkout session', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const userId = cryptoRandomString({ length: 10 })
const workspacePlan: PaidWorkspacePlans = 'pro'
const billingInterval: WorkspacePlanBillingIntervals = 'monthly'
let existingCheckoutSession: CheckoutSession | undefined = {
@@ -466,6 +487,7 @@ describe('checkout @gatekeeper', () => {
url: 'https://example.com',
currency: 'usd',
workspaceId,
userId,
workspacePlan
}
const err = await expectToThrow(async () => {
@@ -487,6 +509,7 @@ describe('checkout @gatekeeper', () => {
},
saveCheckoutSession: async () => {}
})({
userId,
workspaceId,
billingInterval,
workspacePlan,
@@ -500,11 +523,13 @@ describe('checkout @gatekeeper', () => {
it('creates and stores a checkout for CANCELED workspaces', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const userId = cryptoRandomString({ length: 10 })
const workspacePlan: PaidWorkspacePlans = 'pro'
const billingInterval: WorkspacePlanBillingIntervals = 'monthly'
const checkoutSession: CheckoutSession = {
id: cryptoRandomString({ length: 10 }),
workspaceId,
userId,
workspacePlan,
url: 'https://example.com',
billingInterval,
@@ -519,6 +544,7 @@ describe('checkout @gatekeeper', () => {
paymentStatus: 'paid',
url: '',
workspaceId,
userId,
workspacePlan: 'team',
currency: 'usd',
createdAt: new Date(),
@@ -544,6 +570,7 @@ describe('checkout @gatekeeper', () => {
}
})({
workspaceId,
userId,
billingInterval,
workspacePlan,
workspaceSlug: cryptoRandomString({ length: 10 }),
@@ -1,6 +1,7 @@
import {
SubscriptionData,
SubscriptionDataInput,
SubscriptionUpdateIntent,
WorkspaceSeatType,
WorkspaceSubscription
} from '@/modules/gatekeeper/domain/billing'
@@ -29,6 +30,7 @@ import { omit } from 'lodash'
import { upgradeWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/services/subscriptions/upgradeWorkspaceSubscription'
import { EventBusEmit } from '@/modules/shared/services/eventBus'
import { GatekeeperEvents } from '@/modules/gatekeeperCore/domain/events'
import { testLogger } from '@/observability/logging'
describe('subscriptions @gatekeeper', () => {
describe('handleSubscriptionUpdateFactory creates a function, that', () => {
@@ -49,7 +51,7 @@ describe('subscriptions @gatekeeper', () => {
emitEvent: async () => {
expect.fail()
}
})({ subscriptionData })
})({ subscriptionData, logger: testLogger })
})
expect(err.message).to.equal(new WorkspaceSubscriptionNotFoundError().message)
})
@@ -70,7 +72,7 @@ describe('subscriptions @gatekeeper', () => {
emitEvent: async () => {
expect.fail()
}
})({ subscriptionData })
})({ subscriptionData, logger: testLogger })
})
it('throws if workspacePlan is not found', async () => {
const subscriptionData = createTestSubscriptionData()
@@ -88,7 +90,7 @@ describe('subscriptions @gatekeeper', () => {
emitEvent: async () => {
expect.fail()
}
})({ subscriptionData })
})({ subscriptionData, logger: testLogger })
})
expect(err.message).to.equal(new WorkspacePlanNotFoundError().message)
})
@@ -119,7 +121,7 @@ describe('subscriptions @gatekeeper', () => {
emitEvent: async () => {
expect.fail()
}
})({ subscriptionData })
})({ subscriptionData, logger: testLogger })
})
expect(err.message).to.equal(new WorkspacePlanMismatchError().message)
})
@@ -160,7 +162,7 @@ describe('subscriptions @gatekeeper', () => {
updatedPlan = workspacePlan
},
emitEvent
})({ subscriptionData })
})({ subscriptionData, logger: testLogger })
expect(updatedPlan!.status).to.be.equal('cancelationScheduled')
expect(updatedSubscription!.updatedAt).to.be.greaterThanOrEqual(
workspaceSubscription.updatedAt
@@ -190,6 +192,7 @@ describe('subscriptions @gatekeeper', () => {
billingInterval: 'monthly' as const,
createdAt: new Date(),
updatedAt: new Date(),
updateIntent: null,
currency: 'usd' as const,
currentBillingCycleEnd: new Date(),
workspaceId
@@ -220,7 +223,7 @@ describe('subscriptions @gatekeeper', () => {
updatedPlan = workspacePlan
},
emitEvent
})({ subscriptionData })
})({ subscriptionData, logger: testLogger })
expect(updatedPlan!.status).to.be.equal('valid')
expect(updatedSubscription!.updatedAt).to.be.greaterThanOrEqual(
workspaceSubscription.updatedAt
@@ -239,6 +242,91 @@ describe('subscriptions @gatekeeper', () => {
'previousSubscription.totalEditorSeats': 3
})
})
it('updates the plan with the subscription update intent', async () => {
const subscriptionData = createTestSubscriptionData({
status: 'active',
cancelAt: null
})
const workspaceId = cryptoRandomString({ length: 10 })
const userId = cryptoRandomString({ length: 10 })
const now = new Date()
const oneMonthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
const inOneYear = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000)
const workspaceSubscription = {
subscriptionData,
billingInterval: 'monthly' as const,
createdAt: oneMonthAgo,
updatedAt: oneMonthAgo,
updateIntent: {
userId,
planName: 'proUnlimited' as const,
billingInterval: 'yearly' as const,
currentBillingCycleEnd: inOneYear,
updatedAt: now,
currency: 'usd' as const,
products: [
{
priceId: subscriptionData.products[0].priceId,
productId: subscriptionData.products[0].productId,
quantity: subscriptionData.products[0].quantity
}
]
},
currency: 'usd' as const,
currentBillingCycleEnd: new Date(),
workspaceId
}
let updatedSubscription: WorkspaceSubscription | undefined = undefined
let updatedPlan: WorkspacePlan | undefined = undefined
let emittedEventName: string | undefined = undefined
let emittedEventPayload: unknown = undefined
const emitEvent: EventBusEmit = async ({ eventName, payload }) => {
emittedEventName = eventName
emittedEventPayload = payload
}
await handleSubscriptionUpdateFactory({
getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription,
getWorkspacePlan: async () => ({
name: PaidWorkspacePlans.Team,
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
status: 'paymentFailed'
}),
upsertWorkspaceSubscription: async ({ workspaceSubscription }) => {
updatedSubscription = workspaceSubscription
},
upsertPaidWorkspacePlan: async ({ workspacePlan }) => {
updatedPlan = workspacePlan
},
emitEvent
})({ subscriptionData, logger: testLogger })
expect(updatedPlan!.name).to.be.equal('proUnlimited')
expect(updatedPlan!.status).to.be.equal('valid')
expect(updatedSubscription).to.be.deep.equal({
workspaceId,
billingInterval: 'yearly',
currentBillingCycleEnd: inOneYear,
updateIntent: null,
currency: 'usd',
updatedAt: now,
createdAt: oneMonthAgo,
subscriptionData
})
expect(emittedEventName).to.eq('gatekeeper.workspace-subscription-updated')
expect(emittedEventPayload).to.have.nested.include({
'workspacePlan.status': 'valid'
})
expect(emittedEventPayload).to.have.nested.include({
'subscription.totalEditorSeats': 3
})
expect(emittedEventPayload).to.have.nested.include({
'previousSubscription.totalEditorSeats': 3
})
})
it('sets the state to paymentFailed', async () => {
const subscriptionData = createTestSubscriptionData({
status: 'past_due'
@@ -275,7 +363,7 @@ describe('subscriptions @gatekeeper', () => {
updatedPlan = workspacePlan
},
emitEvent
})({ subscriptionData })
})({ subscriptionData, logger: testLogger })
expect(updatedPlan!.status).to.be.equal('paymentFailed')
expect(updatedSubscription!.updatedAt).to.be.greaterThanOrEqual(
workspaceSubscription.updatedAt
@@ -304,6 +392,7 @@ describe('subscriptions @gatekeeper', () => {
billingInterval: 'monthly' as const,
createdAt: new Date(),
updatedAt: new Date(),
updateIntent: null,
currency: 'usd' as const,
currentBillingCycleEnd: new Date(),
workspaceId
@@ -334,7 +423,7 @@ describe('subscriptions @gatekeeper', () => {
updatedPlan = workspacePlan
},
emitEvent
})({ subscriptionData })
})({ subscriptionData, logger: testLogger })
expect(updatedPlan!.status).to.be.equal('canceled')
expect(updatedSubscription!.updatedAt).to.be.greaterThanOrEqual(
workspaceSubscription.updatedAt
@@ -385,7 +474,7 @@ describe('subscriptions @gatekeeper', () => {
emitEvent: async () => {
expect.fail()
}
})({ subscriptionData })
})({ subscriptionData, logger: testLogger })
})
})
})
@@ -1072,6 +1161,7 @@ describe('subscriptions @gatekeeper', () => {
describe('upgradeWorkspaceSubscriptionFactory creates a function, that', () => {
it('throws WorkspacePlanNotFound if no plan can be found', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const userId = cryptoRandomString({ length: 10 })
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
getWorkspacePlan: async () => null,
getWorkspacePlanProductId: () => {
@@ -1086,21 +1176,16 @@ describe('subscriptions @gatekeeper', () => {
reconcileSubscriptionData: () => {
expect.fail()
},
upsertWorkspacePlan: () => {
expect.fail()
},
updateWorkspaceSubscription: () => {
expect.fail()
},
countSeatsByTypeInWorkspace: () => {
expect.fail()
},
emitEvent: () => {
expect.fail()
}
})
const err = await expectToThrow(async () => {
await upgradeWorkspaceSubscription({
userId,
workspaceId,
targetPlan: 'team',
billingInterval: 'monthly'
@@ -1112,6 +1197,7 @@ describe('subscriptions @gatekeeper', () => {
;(['unlimited', 'academia'] as const).forEach((plan) => {
it(`throws WorkspaceNotPaidPlan for ${plan}`, async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const userId = cryptoRandomString({ length: 10 })
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
getWorkspacePlan: async () => ({
createdAt: new Date(),
@@ -1132,21 +1218,16 @@ describe('subscriptions @gatekeeper', () => {
reconcileSubscriptionData: () => {
expect.fail()
},
upsertWorkspacePlan: () => {
expect.fail()
},
updateWorkspaceSubscription: () => {
expect.fail()
},
countSeatsByTypeInWorkspace: () => {
expect.fail()
},
emitEvent: () => {
expect.fail()
}
})
const err = await expectToThrow(async () => {
await upgradeWorkspaceSubscription({
userId,
workspaceId,
targetPlan: 'team',
billingInterval: 'monthly'
@@ -1161,6 +1242,7 @@ describe('subscriptions @gatekeeper', () => {
(status) => {
it(`throws WorkspaceNotPaidPlan for ${plan} on a non valid status: ${status}`, async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const userId = cryptoRandomString({ length: 10 })
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
getWorkspacePlan: async () => ({
workspaceId,
@@ -1181,21 +1263,16 @@ describe('subscriptions @gatekeeper', () => {
reconcileSubscriptionData: () => {
expect.fail()
},
upsertWorkspacePlan: () => {
expect.fail()
},
updateWorkspaceSubscription: () => {
expect.fail()
},
countSeatsByTypeInWorkspace: () => {
expect.fail()
},
emitEvent: () => {
expect.fail()
}
})
const err = await expectToThrow(async () => {
await upgradeWorkspaceSubscription({
userId,
workspaceId,
targetPlan: 'pro',
billingInterval: 'monthly'
@@ -1209,6 +1286,7 @@ describe('subscriptions @gatekeeper', () => {
})
it('throws WorkspaceSubscriptionNotFound', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const userId = cryptoRandomString({ length: 10 })
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
getWorkspacePlan: async () => ({
workspaceId,
@@ -1229,21 +1307,16 @@ describe('subscriptions @gatekeeper', () => {
reconcileSubscriptionData: () => {
expect.fail()
},
upsertWorkspacePlan: () => {
expect.fail()
},
updateWorkspaceSubscription: () => {
expect.fail()
},
countSeatsByTypeInWorkspace: () => {
expect.fail()
},
emitEvent: () => {
expect.fail()
}
})
const err = await expectToThrow(async () => {
await upgradeWorkspaceSubscription({
userId,
workspaceId,
targetPlan: 'team',
billingInterval: 'monthly'
@@ -1255,6 +1328,7 @@ describe('subscriptions @gatekeeper', () => {
it('throws WorkspacePlanUpgradeError for downgrading the plan', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const userId = cryptoRandomString({ length: 10 })
const workspaceSubscription = createTestWorkspaceSubscription()
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
getWorkspacePlan: async () => ({
@@ -1276,21 +1350,16 @@ describe('subscriptions @gatekeeper', () => {
reconcileSubscriptionData: () => {
expect.fail()
},
upsertWorkspacePlan: () => {
expect.fail()
},
updateWorkspaceSubscription: () => {
expect.fail()
},
countSeatsByTypeInWorkspace: () => {
expect.fail()
},
emitEvent: () => {
expect.fail()
}
})
const err = await expectToThrow(async () => {
await upgradeWorkspaceSubscription({
userId,
workspaceId,
targetPlan: 'team',
billingInterval: 'yearly'
@@ -1302,6 +1371,7 @@ describe('subscriptions @gatekeeper', () => {
it('throws WorkspacePlanUpgradeError for downgrading the billing interval', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const userId = cryptoRandomString({ length: 10 })
const workspaceSubscription = createTestWorkspaceSubscription({
billingInterval: 'yearly'
})
@@ -1325,21 +1395,16 @@ describe('subscriptions @gatekeeper', () => {
reconcileSubscriptionData: () => {
expect.fail()
},
upsertWorkspacePlan: () => {
expect.fail()
},
updateWorkspaceSubscription: () => {
expect.fail()
},
countSeatsByTypeInWorkspace: () => {
expect.fail()
},
emitEvent: () => {
expect.fail()
}
})
const err = await expectToThrow(async () => {
await upgradeWorkspaceSubscription({
userId,
workspaceId,
targetPlan: 'team',
billingInterval: 'monthly'
@@ -1350,6 +1415,7 @@ describe('subscriptions @gatekeeper', () => {
})
it('throws WorkspacePlanDowngradeError for noop requests', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const userId = cryptoRandomString({ length: 10 })
const workspaceSubscription = createTestWorkspaceSubscription({
billingInterval: 'monthly'
})
@@ -1373,21 +1439,16 @@ describe('subscriptions @gatekeeper', () => {
reconcileSubscriptionData: () => {
expect.fail()
},
upsertWorkspacePlan: () => {
expect.fail()
},
updateWorkspaceSubscription: () => {
expect.fail()
},
countSeatsByTypeInWorkspace: () => {
expect.fail()
},
emitEvent: () => {
expect.fail()
}
})
const err = await expectToThrow(async () => {
await upgradeWorkspaceSubscription({
userId,
workspaceId,
targetPlan: 'team',
billingInterval: 'monthly'
@@ -1398,6 +1459,7 @@ describe('subscriptions @gatekeeper', () => {
})
it('throws WorkspacePlanMismatchError if subscription has no seats for the current plan', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const userId = cryptoRandomString({ length: 10 })
const subscriptionData: SubscriptionData = {
cancelAt: null,
customerId: cryptoRandomString({ length: 10 }),
@@ -1429,21 +1491,16 @@ describe('subscriptions @gatekeeper', () => {
reconcileSubscriptionData: () => {
expect.fail()
},
upsertWorkspacePlan: () => {
expect.fail()
},
updateWorkspaceSubscription: () => {
expect.fail()
},
countSeatsByTypeInWorkspace: () => {
expect.fail()
},
emitEvent: () => {
expect.fail()
}
})
const err = await expectToThrow(async () => {
await upgradeWorkspaceSubscription({
userId,
workspaceId,
targetPlan: 'pro',
billingInterval: 'monthly'
@@ -1454,6 +1511,7 @@ describe('subscriptions @gatekeeper', () => {
})
it('replaces current products with new product', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const userId = cryptoRandomString({ length: 10 })
const subscriptionData: SubscriptionData = {
cancelAt: null,
customerId: cryptoRandomString({ length: 10 }),
@@ -1475,10 +1533,7 @@ describe('subscriptions @gatekeeper', () => {
})
let reconciledSubscriptionData: SubscriptionDataInput | undefined = undefined
let updatedWorkspacePlan: WorkspacePlan | undefined = undefined
let updatedWorkspaceSubscription: WorkspaceSubscription | undefined = undefined
let emitedEventName: string | undefined = undefined
let emitedEventPayload: unknown = undefined
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
getWorkspacePlan: async () => ({
workspaceId,
@@ -1508,49 +1563,38 @@ describe('subscriptions @gatekeeper', () => {
reconcileSubscriptionData: async ({ subscriptionData }) => {
reconciledSubscriptionData = subscriptionData
},
upsertWorkspacePlan: async ({ workspacePlan }) => {
updatedWorkspacePlan = workspacePlan
},
updateWorkspaceSubscription: async ({ workspaceSubscription }) => {
updatedWorkspaceSubscription = workspaceSubscription
},
countSeatsByTypeInWorkspace: async () => {
return 4
},
emitEvent: async ({ eventName, payload }) => {
emitedEventName = eventName
emitedEventPayload = payload
}
})
await upgradeWorkspaceSubscription({
userId,
workspaceId,
targetPlan: 'pro',
billingInterval: 'yearly'
})
expect(updatedWorkspacePlan!.name).to.equal('pro')
const updateIntent = updatedWorkspaceSubscription!
.updateIntent as SubscriptionUpdateIntent
expect(updateIntent).to.deep.contain({
billingInterval: 'yearly',
planName: 'pro',
products: [
{
productId: 'proProduct',
priceId: 'newPlanPrice',
quantity: 4
}
]
})
expect(reconciledSubscriptionData!.products.length).to.equal(1)
expect(updatedWorkspaceSubscription!.billingInterval === 'yearly')
expect(
reconciledSubscriptionData!.products.find((p) => p.productId === 'proProduct')!
.quantity
).to.equal(4)
const newProduct = reconciledSubscriptionData!.products.find(
(p) => p.productId === 'proProduct'
)
expect(newProduct!.quantity).to.equal(4)
expect(newProduct!.priceId).to.equal('newPlanPrice')
expect(emitedEventName).to.eq('gatekeeper.workspace-plan-updated')
expect(emitedEventPayload).to.deep.eq({
workspacePlan: {
workspaceId,
status: 'valid',
name: 'pro'
},
previousPlan: {
name: 'team'
}
})
})
})
describe('getTotalSeatsCountByPlanFactory returns a function that, ', () => {
@@ -0,0 +1,42 @@
import { Knex } from 'knex'
const TABLE_NAME = 'workspace_checkout_sessions'
const COLUMN_NAME = 'userId'
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable(TABLE_NAME, (table) => {
table.string(COLUMN_NAME).nullable()
})
// only on migration we are assuming its a random workspace admin who created the session
// these are ongoing sessions, wont be recorded as activity
const workspaceIds: { workspaceId: string }[] = await knex
.select('workspaceId')
.from(TABLE_NAME)
const admins: { workspaceId: string; userId: string }[] = await knex
.select('workspace_acl.workspaceId', 'workspace_acl.userId')
.from('workspace_acl')
.where({ role: 'workspace:admin' })
.join(
'workspace_checkout_sessions',
'workspace_acl.workspaceId',
'workspace_checkout_sessions.workspaceId'
)
for (const { workspaceId } of workspaceIds) {
const admin = admins.find((a) => a.workspaceId === workspaceId)
await knex(TABLE_NAME)
.update({ [COLUMN_NAME]: admin?.userId || '' }) // fallback to empty string if no admin found (should not happen)
.where({ workspaceId })
}
await knex.schema.alterTable(TABLE_NAME, (table) => {
table.string(COLUMN_NAME).notNullable().alter()
})
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable(TABLE_NAME, (table) => {
table.dropColumn(COLUMN_NAME)
})
}
@@ -0,0 +1,13 @@
import { Knex } from 'knex'
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable('workspace_subscriptions', (table) => {
table.jsonb('updateIntent').defaultTo(null)
})
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable('workspace_subscriptions', (table) => {
table.dropColumn('updateIntent')
})
}
@@ -290,6 +290,7 @@ export const createTestWorkspace = async (
currentBillingCycleEnd: dayjs().add(1, 'month').toDate(),
billingInterval: 'monthly',
currency: 'usd',
updateIntent: null,
subscriptionData: {
subscriptionId: cryptoRandomString({ length: 10 }),
customerId: cryptoRandomString({ length: 10 }),