Files
speckle-server/packages/server/modules/gatekeeper/services/subscriptions.ts
T
Gergő Jedlicska da7f0dda0e gergo/web 2047 user joins the workspace event (#3412)
* feat(gatekeeper): add gatekeeper module feature flag

* feat(gatekeeper): add workspace pricing table domain

* feat(gatekeeper): add checkout session creation

* feat(gatekeeper): verify stripe signature

* wip(gatekeeper): checkout callbacks

* feat(gatekeeper): add unlimited and academia plan types

* refactor(envHelper): getStringFromEnv helper

* chore(gatekeeper): add future todos

* feat(gatekeeper): add productId to the subscription domain

* feat(gatekeeper): add in memory repositories

* feat(gatekeeper): add more errors

* feat(gatekeeper): complete checkout session service

* feat(gatekeeper): add stripe client implementation

* feat(gatekeeper): add checkout session completion webhook callback path

* feat(gendo): fix not needing env vars if gendo module is not enabled

* feat(gatekeeper): require a license for billing

* chore(gatekeeper): cleanup before testing

* feat(gatekeeper): subscriptionData parsing model

* ci: add billing integration and gatekeeper modules to test config

* test(gatekeeper): add checkout service tests

* feat(gatekeeper): make completeCheckout callback idempotent properly

* feat(gatekeeper): move to knex based repositories

* test(gatekeeper): billing repository tests

* feat(gatekeeper): add yearly billing cycle toggle

* feat(ci): add stripe integration context to test job

* feat(billingPage): conditionally render the checkout CTAs

* fix(gatekeeper): remove flaky test condition

* feat(helm): add billing integration feature flag

* WIP billing gql api

* feat(gatekeeper): cancel checkout session api

* feat(gatekeeper): handle existing checkout sessions, when trying to create a new one

* feat(gatekeeper): add workspace plans gql api

* feat(gatekeeper): handle cancelation and subscription updates

* fix(gatekeeper): scope initialization

* fix(gatekeeper): eliminate stripe client import sideeffect

* fix(gatekeeper): eliminate stripe client import sideeffect 2

* feat(gatekeeper): upsize subscription on workspace role change

* feat(shared): add command pattern implementation

* refactor(eventBus): remove return capabilities from the event bus

* refactor(workspaces): use new commandFactory in workspace resolver

* feat(core): facelift taskLock

* feat(gatekeeper): shedule subscription downscale

* feat(gatekeeper): manage subscription downscale

* feat(gatekeeper): get workspace subscriptions, that are about to expire

* feat(gatekeeper): manage subscription downscale

* fix(gatekeeper): do not update subscription to canceled subs

* ci: bump postgres and max connections

* feat(workspaces): fix command factory event bugs
2024-10-30 15:51:40 +01:00

333 lines
11 KiB
TypeScript

import { Logger } from '@/logging/logging'
import {
GetWorkspacePlan,
GetWorkspacePlanPrice,
GetWorkspacePlanProductId,
GetWorkspaceSubscription,
GetWorkspaceSubscriptionBySubscriptionId,
GetWorkspaceSubscriptions,
PaidWorkspacePlanStatuses,
ReconcileSubscriptionData,
SubscriptionData,
SubscriptionDataInput,
UpsertPaidWorkspacePlan,
UpsertWorkspaceSubscription,
WorkspaceSubscription
} from '@/modules/gatekeeper/domain/billing'
import { WorkspacePricingPlans } from '@/modules/gatekeeper/domain/workspacePricing'
import {
WorkspacePlanMismatchError,
WorkspacePlanNotFoundError,
WorkspaceSubscriptionNotFoundError
} from '@/modules/gatekeeper/errors/billing'
import { CountWorkspaceRoleWithOptionalProjectRole } from '@/modules/workspaces/domain/operations'
import { throwUncoveredError, WorkspaceRoles } from '@speckle/shared'
import { cloneDeep, isEqual, sum } from 'lodash'
export const handleSubscriptionUpdateFactory =
({
upsertPaidWorkspacePlan,
getWorkspacePlan,
getWorkspaceSubscriptionBySubscriptionId,
upsertWorkspaceSubscription
}: {
getWorkspacePlan: GetWorkspacePlan
upsertPaidWorkspacePlan: UpsertPaidWorkspacePlan
getWorkspaceSubscriptionBySubscriptionId: GetWorkspaceSubscriptionBySubscriptionId
upsertWorkspaceSubscription: UpsertWorkspaceSubscription
}) =>
async ({ subscriptionData }: { subscriptionData: SubscriptionData }) => {
// we're only handling marking the sub scheduled for cancelation right now
const subscription = await getWorkspaceSubscriptionBySubscriptionId({
subscriptionId: subscriptionData.subscriptionId
})
if (!subscription) 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) {
switch (workspacePlan.name) {
case 'team':
case 'pro':
case 'business':
break
case 'unlimited':
case 'academia':
throw new WorkspacePlanMismatchError()
default:
throwUncoveredError(workspacePlan)
}
await upsertPaidWorkspacePlan({
workspacePlan: { ...workspacePlan, status }
})
// if there is a status in the sub, we recognize, we need to update our state
await upsertWorkspaceSubscription({
workspaceSubscription: {
...subscription,
updatedAt: new Date(),
subscriptionData
}
})
}
}
export const addWorkspaceSubscriptionSeatIfNeededFactory =
({
getWorkspacePlan,
getWorkspaceSubscription,
countWorkspaceRole,
getWorkspacePlanProductId,
getWorkspacePlanPrice,
reconcileSubscriptionData
}: {
getWorkspacePlan: GetWorkspacePlan
getWorkspaceSubscription: GetWorkspaceSubscription
countWorkspaceRole: CountWorkspaceRoleWithOptionalProjectRole
getWorkspacePlanProductId: GetWorkspacePlanProductId
getWorkspacePlanPrice: GetWorkspacePlanPrice
reconcileSubscriptionData: ReconcileSubscriptionData
}) =>
async ({ workspaceId, role }: { workspaceId: string; role: WorkspaceRoles }) => {
const workspacePlan = await getWorkspacePlan({ workspaceId })
// if (!workspacePlan) throw new WorkspacePlanNotFoundError()
if (!workspacePlan) return
const workspaceSubscription = await getWorkspaceSubscription({ workspaceId })
if (!workspaceSubscription) throw new WorkspaceSubscriptionNotFoundError()
switch (workspacePlan.name) {
case 'team':
case 'pro':
case 'business':
break
case 'unlimited':
case 'academia':
throw new WorkspacePlanMismatchError()
default:
throwUncoveredError(workspacePlan)
}
if (workspacePlan.status === 'canceled') return
let productId: string
let priceId: string
let roleCount: number
switch (role) {
case 'workspace:guest':
roleCount = await countWorkspaceRole({ workspaceId, workspaceRole: role })
productId = getWorkspacePlanProductId({ workspacePlan: 'guest' })
priceId = getWorkspacePlanPrice({
workspacePlan: 'guest',
billingInterval: workspaceSubscription.billingInterval
})
break
case 'workspace:admin':
case 'workspace:member':
roleCount = sum(
await Promise.all([
countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:admin' }),
countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:member' })
])
)
productId = getWorkspacePlanProductId({ workspacePlan: workspacePlan.name })
priceId = getWorkspacePlanPrice({
workspacePlan: workspacePlan.name,
billingInterval: workspaceSubscription.billingInterval
})
break
default:
throwUncoveredError(role)
}
const subscriptionData: SubscriptionDataInput = cloneDeep(
workspaceSubscription.subscriptionData
)
const currentPlanProduct = subscriptionData.products.find(
(product) => product.productId === productId
)
if (!currentPlanProduct) {
subscriptionData.products.push({ productId, priceId, quantity: roleCount })
} else {
// if there is enough seats, we do not have to do anything
if (currentPlanProduct.quantity >= roleCount) return
currentPlanProduct.quantity = roleCount
}
await reconcileSubscriptionData({ subscriptionData, applyProrotation: true })
}
const mutateSubscriptionDataWithNewValidSeatNumbers = ({
seatCount,
workspacePlan,
getWorkspacePlanProductId,
subscriptionData
}: {
seatCount: number
workspacePlan: WorkspacePricingPlans
getWorkspacePlanProductId: GetWorkspacePlanProductId
subscriptionData: SubscriptionData
}): void => {
const productId = getWorkspacePlanProductId({ workspacePlan })
const product = subscriptionData.products.find(
(product) => product.productId === productId
)
if (seatCount < 0) throw new Error('Invalid seat count, cannot be negative')
if (seatCount === 0 && product === undefined) return
if (seatCount === 0 && product !== undefined) {
const prodIndex = subscriptionData.products.indexOf(product)
subscriptionData.products.splice(prodIndex, 1)
} else if (product !== undefined && product.quantity >= seatCount) {
product.quantity = seatCount
} else {
throw new Error('Invalid subscription state')
}
}
const calculateNewBillingCycleEnd = ({
workspaceSubscription
}: {
workspaceSubscription: WorkspaceSubscription
}): Date => {
const newBillingCycleEnd = new Date(workspaceSubscription.currentBillingCycleEnd)
switch (workspaceSubscription.billingInterval) {
case 'monthly':
newBillingCycleEnd.setMonth(newBillingCycleEnd.getMonth() + 1)
break
case 'yearly':
newBillingCycleEnd.setFullYear(newBillingCycleEnd.getFullYear() + 1)
break
default:
throwUncoveredError(workspaceSubscription.billingInterval)
}
return newBillingCycleEnd
}
type DownscaleWorkspaceSubscription = (args: {
workspaceSubscription: WorkspaceSubscription
}) => Promise<boolean>
export const downscaleWorkspaceSubscriptionFactory =
({
getWorkspacePlan,
countWorkspaceRole,
getWorkspacePlanProductId,
reconcileSubscriptionData
}: {
getWorkspacePlan: GetWorkspacePlan
countWorkspaceRole: CountWorkspaceRoleWithOptionalProjectRole
getWorkspacePlanProductId: GetWorkspacePlanProductId
reconcileSubscriptionData: ReconcileSubscriptionData
}): DownscaleWorkspaceSubscription =>
async ({ workspaceSubscription }) => {
const workspaceId = workspaceSubscription.workspaceId
const workspacePlan = await getWorkspacePlan({ workspaceId })
if (!workspacePlan) throw new WorkspacePlanNotFoundError()
switch (workspacePlan.name) {
case 'team':
case 'pro':
case 'business':
break
case 'unlimited':
case 'academia':
throw new WorkspacePlanMismatchError()
default:
throwUncoveredError(workspacePlan)
}
if (workspacePlan.status === 'canceled') return false
const [guestCount, memberCount, adminCount] = await Promise.all([
countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:guest' }),
countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:member' }),
countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:admin' })
])
const subscriptionData = cloneDeep(workspaceSubscription.subscriptionData)
mutateSubscriptionDataWithNewValidSeatNumbers({
seatCount: guestCount,
workspacePlan: 'guest',
getWorkspacePlanProductId,
subscriptionData
})
mutateSubscriptionDataWithNewValidSeatNumbers({
seatCount: memberCount + adminCount,
workspacePlan: workspacePlan.name,
getWorkspacePlanProductId,
subscriptionData
})
if (!isEqual(subscriptionData, workspaceSubscription.subscriptionData)) {
await reconcileSubscriptionData({ subscriptionData, applyProrotation: false })
return true
}
return false
}
export const manageSubscriptionDownscaleFactory =
({
logger,
getWorkspaceSubscriptions,
downscaleWorkspaceSubscription,
updateWorkspaceSubscription
}: {
getWorkspaceSubscriptions: GetWorkspaceSubscriptions
downscaleWorkspaceSubscription: DownscaleWorkspaceSubscription
updateWorkspaceSubscription: UpsertWorkspaceSubscription
logger: Logger
}) =>
async () => {
const subscriptions = await getWorkspaceSubscriptions()
for (const workspaceSubscription of subscriptions) {
const log = logger.child({ workspaceId: workspaceSubscription.workspaceId })
try {
const subDownscaled = await downscaleWorkspaceSubscription({
workspaceSubscription
})
if (subDownscaled) {
log.info(
'Downscaled workspace subscription to match the current workspace team'
)
} else {
log.info('Did not need to downscale the workspace subscription')
}
} catch (err) {
log.error({ err }, 'Failed to downscale workspace subscription')
}
const newBillingCycleEnd = calculateNewBillingCycleEnd({ workspaceSubscription })
const updatedWorkspaceSubscription = {
...workspaceSubscription,
currentBillingCycleEnd: newBillingCycleEnd
}
await updateWorkspaceSubscription({
workspaceSubscription: updatedWorkspaceSubscription
})
log.info({ updatedWorkspaceSubscription }, 'Updated workspace billing cycle end')
}
}