Files
speckle-server/packages/server/modules/gatekeeper/services/subscriptions.ts
T
Daniel Gak Anagrov a4ab20c938 feat(activity): add workspace billing events and seats to activity (#4944)
* feat: improved gatekeeper eventsfor workspace history
* feat: userId on seat assign
* feat: record workspace seat events
* feat: workspace removals as activity
* feat: emit role and seat events on create workspace
* fix: reordered events for workspace creation
2025-06-30 16:49:36 +02:00

359 lines
11 KiB
TypeScript

import {
getSubscriptionState,
GetWorkspacePlan,
GetWorkspacePlanPriceId,
GetWorkspacePlanProductId,
GetWorkspaceSubscription,
GetWorkspaceSubscriptionBySubscriptionId,
ReconcileSubscriptionData,
SubscriptionData,
SubscriptionDataInput,
UpsertPaidWorkspacePlan,
UpsertWorkspaceSubscription,
WorkspaceSeatType
} from '@/modules/gatekeeper/domain/billing'
import {
WorkspacePlanMismatchError,
WorkspacePlanNotFoundError,
WorkspaceSubscriptionNotFoundError
} from '@/modules/gatekeeper/errors/billing'
import {
PaidWorkspacePlans,
PaidWorkspacePlanStatuses,
throwUncoveredError,
WorkspacePlans
} from '@speckle/shared'
import { cloneDeep, isEqual, omit } 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 =
({
upsertPaidWorkspacePlan,
getWorkspacePlan,
getWorkspaceSubscriptionBySubscriptionId,
upsertWorkspaceSubscription,
emitEvent
}: {
getWorkspacePlan: GetWorkspacePlan
upsertPaidWorkspacePlan: UpsertPaidWorkspacePlan
getWorkspaceSubscriptionBySubscriptionId: GetWorkspaceSubscriptionBySubscriptionId
upsertWorkspaceSubscription: UpsertWorkspaceSubscription
emitEvent: EventBusEmit
}) =>
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
})
if (!subscription) {
if (subscriptionData.status === 'incomplete') {
// the checkout was not completed, so not finding a matching workspace subscription is expected
return
}
throw new WorkspaceSubscriptionNotFoundError()
}
const workspacePlan = await getWorkspacePlan({
workspaceId: subscription.workspaceId
})
if (!workspacePlan) throw new WorkspacePlanNotFoundError()
let status: PaidWorkspacePlanStatuses | undefined = undefined
if (
subscriptionData.status === 'active' &&
subscriptionData.cancelAt &&
subscriptionData.cancelAt > new Date()
) {
status = 'cancelationScheduled'
} else if (
subscriptionData.status === 'active' &&
subscriptionData.cancelAt === null
) {
status = 'valid'
} else if (subscriptionData.status === 'past_due') {
status = 'paymentFailed'
} else if (subscriptionData.status === 'canceled') {
status = 'canceled'
}
if (!status) {
logger.info({ workspaceId: subscription.workspaceId }, 'Nothing to update')
return
}
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)
}
const updateIntent = subscription.updateIntent
let planName
let billingInterval
let currentBillingCycleEnd
let currency
let updatedAt
let userId
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
userId = updateIntent.userId
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
},
'Fatal: Stripe product ID mismatch with subscription update intent'
)
}
} else {
userId = null
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
})
const payload = {
userId,
workspacePlan: newWorkspacePlan,
previousWorkspacePlan: workspacePlan,
subscription: getSubscriptionState(newSubscription),
previousSubscription: getSubscriptionState(subscription)
}
const planHasChanged = !isEqual(
omit(payload.workspacePlan, ['updatedAt', 'createdAt']),
omit(payload.previousWorkspacePlan, ['updatedAt', 'createdAt'])
)
if (planHasChanged) {
await emitEvent({
eventName: GatekeeperEvents.WorkspacePlanUpdated,
payload: {
userId,
workspacePlan: payload.workspacePlan,
previousWorkspacePlan: payload.previousWorkspacePlan
}
})
}
const susbcriptionHasChanged = !isEqual(
payload.subscription,
payload.previousSubscription
)
if (planHasChanged || susbcriptionHasChanged) {
await emitEvent({
eventName: GatekeeperEvents.WorkspaceSubscriptionUpdated,
payload
})
}
}
export const addWorkspaceSubscriptionSeatIfNeededFactory =
({
getWorkspacePlan,
getWorkspaceSubscription,
getWorkspacePlanProductId,
getWorkspacePlanPriceId,
reconcileSubscriptionData,
countSeatsByTypeInWorkspace,
upsertWorkspaceSubscription
}: {
getWorkspacePlan: GetWorkspacePlan
getWorkspaceSubscription: GetWorkspaceSubscription
getWorkspacePlanProductId: GetWorkspacePlanProductId
getWorkspacePlanPriceId: GetWorkspacePlanPriceId
reconcileSubscriptionData: ReconcileSubscriptionData
countSeatsByTypeInWorkspace: CountSeatsByTypeInWorkspace
upsertWorkspaceSubscription: UpsertWorkspaceSubscription
}) =>
async ({
updatedByUserId,
workspaceId,
seatType
}: {
updatedByUserId: string
workspaceId: string
seatType: WorkspaceSeatType
}) => {
const workspacePlan = await getWorkspacePlan({ workspaceId })
// if (!workspacePlan) throw new WorkspacePlanNotFoundError()
if (!workspacePlan) return
const workspaceSubscription = await getWorkspaceSubscription({ workspaceId })
if (!workspaceSubscription) return
// if (!workspaceSubscription) throw new WorkspaceSubscriptionNotFoundError()
switch (workspacePlan.name) {
case WorkspacePlans.Team:
case WorkspacePlans.TeamUnlimited:
case WorkspacePlans.Pro:
case WorkspacePlans.ProUnlimited:
// If viewer seat type, we don't need to do anything
if (seatType === WorkspaceSeatType.Viewer) {
return
} else {
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 (workspacePlan.status === 'canceled') return
// New logic, only based on seat types
const productAmount = await countSeatsByTypeInWorkspace({
workspaceId,
type: seatType
})
const productId = getWorkspacePlanProductId({ workspacePlan: workspacePlan.name })
const priceId = getWorkspacePlanPriceId({
workspacePlan: workspacePlan.name,
billingInterval: workspaceSubscription.billingInterval,
currency: workspaceSubscription.currency
})
const subscriptionData: SubscriptionDataInput = cloneDeep(
workspaceSubscription.subscriptionData
)
const currentPlanProduct = subscriptionData.products.find(
(product) => product.productId === productId
)
if (!currentPlanProduct) {
subscriptionData.products.push({ productId, priceId, quantity: productAmount })
} else {
// if there is enough seats, we do not have to do anything
if (currentPlanProduct.quantity >= productAmount) return
currentPlanProduct.quantity = productAmount
}
await upsertWorkspaceSubscription({
workspaceSubscription: {
...workspaceSubscription,
updateIntent: {
userId: updatedByUserId,
products: subscriptionData.products,
planName: workspacePlan.name,
currentBillingCycleEnd: workspaceSubscription.currentBillingCycleEnd,
currency: workspaceSubscription.currency,
billingInterval: workspaceSubscription.billingInterval,
updatedAt: new Date()
}
}
})
await reconcileSubscriptionData({
subscriptionData,
prorationBehavior: 'always_invoice'
})
}
export const getTotalSeatsCountByPlanFactory =
({
getWorkspacePlanProductId
}: {
getWorkspacePlanProductId: GetWorkspacePlanProductId
}) =>
({
workspacePlan,
subscriptionData
}: {
workspacePlan: PaidWorkspacePlans
subscriptionData: Pick<SubscriptionData, 'products'>
}) => {
const productId = getWorkspacePlanProductId({
workspacePlan
})
const product = subscriptionData.products.find(
(product) => product.productId === productId
)
return product?.quantity ?? 0
}