From 51d6a8dd67adcf1f345328ec6d4d79f48af0f7c6 Mon Sep 17 00:00:00 2001 From: Daniel Gak Anagrov Date: Tue, 24 Jun 2025 15:34:26 +0200 Subject: [PATCH] feat(activity): added user info to checkout_subscription and subscription upgrade (#4967) * feat: added userId to checkout_subscription * feat: add update intent to subscription --- .../clients/checkout/createCheckoutSession.ts | 2 + .../modules/gatekeeper/domain/billing.ts | 21 +- .../gatekeeper/graph/resolvers/index.ts | 18 +- .../gatekeeper/repositories/billing.ts | 3 +- .../server/modules/gatekeeper/rest/billing.ts | 4 +- .../modules/gatekeeper/services/checkout.ts | 1 + .../services/checkout/startCheckoutSession.ts | 3 + .../gatekeeper/services/subscriptions.ts | 177 +++++++++++---- .../upgradeWorkspaceSubscription.ts | 60 ++--- .../modules/gatekeeper/tests/helpers.ts | 1 + .../gatekeeper/tests/helpers/workspacePlan.ts | 1 + .../integration/billingRepositories.spec.ts | 4 + .../tests/integration/workspace.graph.spec.ts | 2 + .../gatekeeper/tests/unit/checkout.spec.ts | 27 +++ .../tests/unit/subscriptions.spec.ts | 214 +++++++++++------- ...add_userId_to_workspaceCheckoutSessions.ts | 42 ++++ ...1314_add_uppdateIntent_to_subscriptions.ts | 13 ++ .../workspaces/tests/helpers/creation.ts | 1 + 18 files changed, 416 insertions(+), 178 deletions(-) create mode 100644 packages/server/modules/gatekeeperCore/migrations/20250620072434_add_userId_to_workspaceCheckoutSessions.ts create mode 100644 packages/server/modules/gatekeeperCore/migrations/20250620121314_add_uppdateIntent_to_subscriptions.ts diff --git a/packages/server/modules/gatekeeper/clients/checkout/createCheckoutSession.ts b/packages/server/modules/gatekeeper/clients/checkout/createCheckoutSession.ts index 39a7e591c..79cdcaa68 100644 --- a/packages/server/modules/gatekeeper/clients/checkout/createCheckoutSession.ts +++ b/packages/server/modules/gatekeeper/clients/checkout/createCheckoutSession.ts @@ -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(), diff --git a/packages/server/modules/gatekeeper/domain/billing.ts b/packages/server/modules/gatekeeper/domain/billing.ts index e3a6a9315..c8608485c 100644 --- a/packages/server/modules/gatekeeper/domain/billing.ts +++ b/packages/server/modules/gatekeeper/domain/billing.ts @@ -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 +type SubscriptionIntentProduct = Pick< + SubscriptionProduct, + 'productId' | 'priceId' | 'quantity' +> + export const SubscriptionData = z.object({ subscriptionId: z.string().min(1), customerId: z.string().min(1), diff --git a/packages/server/modules/gatekeeper/graph/resolvers/index.ts b/packages/server/modules/gatekeeper/graph/resolvers/index.ts index 92fb55c73..92e9349e9 100644 --- a/packages/server/modules/gatekeeper/graph/resolvers/index.ts +++ b/packages/server/modules/gatekeeper/graph/resolvers/index.ts @@ -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 diff --git a/packages/server/modules/gatekeeper/repositories/billing.ts b/packages/server/modules/gatekeeper/repositories/billing.ts index 2751646de..4164505e4 100644 --- a/packages/server/modules/gatekeeper/repositories/billing.ts +++ b/packages/server/modules/gatekeeper/repositories/billing.ts @@ -43,7 +43,8 @@ const WorkspaceSubscriptions = buildTableHelper('workspace_subscriptions', [ 'currentBillingCycleEnd', 'billingInterval', 'subscriptionData', - 'currency' + 'currency', + 'updateIntent' ]) const tables = { diff --git a/packages/server/modules/gatekeeper/rest/billing.ts b/packages/server/modules/gatekeeper/rest/billing.ts index 289238c62..30774b65c 100644 --- a/packages/server/modules/gatekeeper/rest/billing.ts +++ b/packages/server/modules/gatekeeper/rest/billing.ts @@ -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', diff --git a/packages/server/modules/gatekeeper/services/checkout.ts b/packages/server/modules/gatekeeper/services/checkout.ts index 648d5ec0a..ed106569f 100644 --- a/packages/server/modules/gatekeeper/services/checkout.ts +++ b/packages/server/modules/gatekeeper/services/checkout.ts @@ -82,6 +82,7 @@ export const completeCheckoutSessionFactory = workspaceId: checkoutSession.workspaceId, billingInterval: checkoutSession.billingInterval, currency: checkoutSession.currency, + updateIntent: null, subscriptionData } diff --git a/packages/server/modules/gatekeeper/services/checkout/startCheckoutSession.ts b/packages/server/modules/gatekeeper/services/checkout/startCheckoutSession.ts index 00f4b6498..c16761c08 100644 --- a/packages/server/modules/gatekeeper/services/checkout/startCheckoutSession.ts +++ b/packages/server/modules/gatekeeper/services/checkout/startCheckoutSession.ts @@ -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, diff --git a/packages/server/modules/gatekeeper/services/subscriptions.ts b/packages/server/modules/gatekeeper/services/subscriptions.ts index 811f012f6..0fbd8457b 100644 --- a/packages/server/modules/gatekeeper/services/subscriptions.ts +++ b/packages/server/modules/gatekeeper/services/subscriptions.ts @@ -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 = diff --git a/packages/server/modules/gatekeeper/services/subscriptions/upgradeWorkspaceSubscription.ts b/packages/server/modules/gatekeeper/services/subscriptions/upgradeWorkspaceSubscription.ts index 9f1e646e5..f6895b512 100644 --- a/packages/server/modules/gatekeeper/services/subscriptions/upgradeWorkspaceSubscription.ts +++ b/packages/server/modules/gatekeeper/services/subscriptions/upgradeWorkspaceSubscription.ts @@ -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 } - }) - } - }) } diff --git a/packages/server/modules/gatekeeper/tests/helpers.ts b/packages/server/modules/gatekeeper/tests/helpers.ts index c6eb6fa57..07b1a5fbd 100644 --- a/packages/server/modules/gatekeeper/tests/helpers.ts +++ b/packages/server/modules/gatekeeper/tests/helpers.ts @@ -37,6 +37,7 @@ export const createTestWorkspaceSubscription = ( updatedAt: new Date(), currentBillingCycleEnd: new Date(), subscriptionData: createTestSubscriptionData(), + updateIntent: null, currency: 'usd', workspaceId: cryptoRandomString({ length: 10 }) } diff --git a/packages/server/modules/gatekeeper/tests/helpers/workspacePlan.ts b/packages/server/modules/gatekeeper/tests/helpers/workspacePlan.ts index 302a98427..3a7ddd33e 100644 --- a/packages/server/modules/gatekeeper/tests/helpers/workspacePlan.ts +++ b/packages/server/modules/gatekeeper/tests/helpers/workspacePlan.ts @@ -31,6 +31,7 @@ export const buildTestWorkspaceSubscription = ( updatedAt: new Date(), currentBillingCycleEnd: new Date(), billingInterval: 'monthly', + updateIntent: {}, currency: 'usd', subscriptionData: buildTestSubscriptionData() }, diff --git a/packages/server/modules/gatekeeper/tests/integration/billingRepositories.spec.ts b/packages/server/modules/gatekeeper/tests/integration/billingRepositories.spec.ts index c8461fd2c..f83b3f8f6 100644 --- a/packages/server/modules/gatekeeper/tests/integration/billingRepositories.spec.ts +++ b/packages/server/modules/gatekeeper/tests/integration/billingRepositories.spec.ts @@ -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', diff --git a/packages/server/modules/gatekeeper/tests/integration/workspace.graph.spec.ts b/packages/server/modules/gatekeeper/tests/integration/workspace.graph.spec.ts index e443a8441..c29a2754d 100644 --- a/packages/server/modules/gatekeeper/tests/integration/workspace.graph.spec.ts +++ b/packages/server/modules/gatekeeper/tests/integration/workspace.graph.spec.ts @@ -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 }), diff --git a/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts b/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts index c21f6c0c1..e724da5da 100644 --- a/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts +++ b/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts @@ -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 }), diff --git a/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts b/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts index ec9c012fd..295cebfb1 100644 --- a/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts +++ b/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts @@ -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, ', () => { diff --git a/packages/server/modules/gatekeeperCore/migrations/20250620072434_add_userId_to_workspaceCheckoutSessions.ts b/packages/server/modules/gatekeeperCore/migrations/20250620072434_add_userId_to_workspaceCheckoutSessions.ts new file mode 100644 index 000000000..aee9c04c9 --- /dev/null +++ b/packages/server/modules/gatekeeperCore/migrations/20250620072434_add_userId_to_workspaceCheckoutSessions.ts @@ -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 { + 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 { + await knex.schema.alterTable(TABLE_NAME, (table) => { + table.dropColumn(COLUMN_NAME) + }) +} diff --git a/packages/server/modules/gatekeeperCore/migrations/20250620121314_add_uppdateIntent_to_subscriptions.ts b/packages/server/modules/gatekeeperCore/migrations/20250620121314_add_uppdateIntent_to_subscriptions.ts new file mode 100644 index 000000000..2b82a15cf --- /dev/null +++ b/packages/server/modules/gatekeeperCore/migrations/20250620121314_add_uppdateIntent_to_subscriptions.ts @@ -0,0 +1,13 @@ +import { Knex } from 'knex' + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable('workspace_subscriptions', (table) => { + table.jsonb('updateIntent').defaultTo(null) + }) +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable('workspace_subscriptions', (table) => { + table.dropColumn('updateIntent') + }) +} diff --git a/packages/server/modules/workspaces/tests/helpers/creation.ts b/packages/server/modules/workspaces/tests/helpers/creation.ts index f51d62b6c..03d8c1820 100644 --- a/packages/server/modules/workspaces/tests/helpers/creation.ts +++ b/packages/server/modules/workspaces/tests/helpers/creation.ts @@ -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 }),