Files
speckle-server/packages/server/modules/gatekeeper/services/subscriptions/upgradeWorkspaceSubscription.ts
T
Kristaps Fabians Geikins bde148f286 chore(server): migrating fully to ESM (#5042)
* wip

* some extra fixes

* stuff kinda works?

* need to figure out mocks

* need to figure out mocks

* fix db listener

* gqlgen fix

* minor gqlgen watch adjustment

* lint fixes

* delete old codegen file

* converting migrations to ESM

* getModuleDIrectory

* vitest sort of works

* added back ts-vitest

* resolve gql double load

* fixing test timeout configs

* TSC lint fix

* fix automate tests

* moar debugging

* debugging

* more debugging

* codegen update

* server works

* yargs migrated

* chore(server): getting rid of global mocks for Server ESM (#5046)

* got rid of email mock

* got rid of comment mocks

* got rid of multi region mocks

* got rid of stripe mock

* admin override mock updated

* removed final mock

* fixing import.meta.resolve calls

* another import.meta.resolve fix

* added requested test

* nyc ESM fix

* removed unneeded deps + linting

* yarn lock forgot to commit

* tryna fix flakyness

* email capture util fix

* sendEmail fix

* fix TSX check

* sender transporter fix + CR comments

* merge main fix

* test fixx

* circleci fix

* gqlgen bigint fix

* error formatter fix

* more error formatting improvements

* esmloader added to Dockerfile

* more dockerfile fixes

* bg jobs fix
2025-07-14 10:26:19 +03:00

193 lines
6.0 KiB
TypeScript

import {
GetWorkspacePlan,
GetWorkspacePlanPriceId,
GetWorkspacePlanProductId,
GetWorkspaceSubscription,
ReconcileSubscriptionData,
SubscriptionDataInput,
UpsertWorkspaceSubscription,
WorkspaceSeatType
} from '@/modules/gatekeeper/domain/billing'
import { CountSeatsByTypeInWorkspace } from '@/modules/gatekeeper/domain/operations'
import {
InvalidWorkspacePlanUpgradeError,
UnsupportedWorkspacePlanError,
WorkspaceNotPaidPlanError,
WorkspacePlanMismatchError,
WorkspacePlanNotFoundError,
WorkspacePlanUpgradeError,
WorkspaceSubscriptionNotFoundError
} from '@/modules/gatekeeper/errors/billing'
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 {
PaidWorkspacePlans,
throwUncoveredError,
WorkspacePlanBillingIntervals,
WorkspacePlans
} from '@speckle/shared'
import { cloneDeep } from 'lodash-es'
export const upgradeWorkspaceSubscriptionFactory =
({
getWorkspacePlan,
getWorkspacePlanProductId,
getWorkspacePlanPriceId,
getWorkspaceSubscription,
reconcileSubscriptionData,
updateWorkspaceSubscription,
countSeatsByTypeInWorkspace
}: {
getWorkspacePlan: GetWorkspacePlan
getWorkspacePlanProductId: GetWorkspacePlanProductId
getWorkspacePlanPriceId: GetWorkspacePlanPriceId
getWorkspaceSubscription: GetWorkspaceSubscription
reconcileSubscriptionData: ReconcileSubscriptionData
updateWorkspaceSubscription: UpsertWorkspaceSubscription
countSeatsByTypeInWorkspace: CountSeatsByTypeInWorkspace
}) =>
async ({
userId,
workspaceId,
targetPlan,
billingInterval
}: {
userId: string
workspaceId: string
targetPlan: PaidWorkspacePlans
billingInterval: WorkspacePlanBillingIntervals
}) => {
const workspacePlan = await getWorkspacePlan({
workspaceId
})
if (!workspacePlan) throw new WorkspacePlanNotFoundError()
switch (workspacePlan.name) {
case WorkspacePlans.Unlimited:
case WorkspacePlans.Academia:
case WorkspacePlans.TeamUnlimitedInvoiced:
case WorkspacePlans.ProUnlimitedInvoiced:
case WorkspacePlans.Enterprise:
case WorkspacePlans.Free: // Upgrade from free is handled through startCheckout since it is from free to paid
throw new WorkspaceNotPaidPlanError()
case WorkspacePlans.Team:
case WorkspacePlans.TeamUnlimited:
case WorkspacePlans.Pro:
case WorkspacePlans.ProUnlimited:
break
default:
throwUncoveredError(workspacePlan)
}
if (!isPaidPlanType(targetPlan)) {
throw new UnsupportedWorkspacePlanError(null, {
info: { currentPlan: workspacePlan.name, targetPlan }
})
}
switch (workspacePlan.status) {
case 'canceled':
case 'cancelationScheduled':
case 'paymentFailed':
throw new WorkspaceNotPaidPlanError()
case 'valid':
break
default:
throwUncoveredError(workspacePlan)
}
const workspaceSubscription = await getWorkspaceSubscription({ workspaceId })
if (!workspaceSubscription) throw new WorkspaceSubscriptionNotFoundError()
if (
workspacePlan.name === targetPlan &&
workspaceSubscription.billingInterval === billingInterval
)
throw new WorkspacePlanUpgradeError("Can't upgrade to the same plan")
const planOrder: Record<PaidWorkspacePlans, number> = {
team: 1,
teamUnlimited: 2,
pro: 3,
proUnlimited: 4
}
if (
!isUpgradeWorkspacePlanValid({ current: workspacePlan.name, upgrade: targetPlan })
) {
if (planOrder[workspacePlan.name] > planOrder[targetPlan]) {
throw new WorkspacePlanUpgradeError("Can't upgrade to a less expensive plan")
}
throw new InvalidWorkspacePlanUpgradeError(null, {
info: { current: workspacePlan.name, upgrade: targetPlan, workspaceId }
})
}
switch (billingInterval) {
case 'monthly':
if (workspaceSubscription.billingInterval === 'yearly')
throw new WorkspacePlanUpgradeError(
"Can't upgrade from yearly to monthly billing cycle"
)
case 'yearly':
break
default:
throwUncoveredError(billingInterval)
}
const currentBillingCycleEnd = calculateNewBillingCycleEnd({
workspaceSubscription
})
const subscriptionData: SubscriptionDataInput = cloneDeep(
workspaceSubscription.subscriptionData
)
const product = subscriptionData.products.find(
(p) =>
p.productId === getWorkspacePlanProductId({ workspacePlan: workspacePlan.name })
)
if (!product) throw new WorkspacePlanMismatchError()
const editorsCount = await countSeatsByTypeInWorkspace({
workspaceId,
type: WorkspaceSeatType.Editor
})
// set current plan seat count to 0
mutateSubscriptionDataWithNewValidSeatNumbers({
seatCount: 0,
getWorkspacePlanProductId,
subscriptionData,
workspacePlan: workspacePlan.name
})
// set target plan and subscription
const newProduct = {
quantity: editorsCount,
productId: getWorkspacePlanProductId({ workspacePlan: targetPlan }),
priceId: getWorkspacePlanPriceId({
workspacePlan: targetPlan,
billingInterval,
currency: workspaceSubscription.currency
})
}
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'
})
}