Files
speckle-server/packages/server/modules/gatekeeper/services/workspaces.ts
T
2024-10-17 07:29:48 +02:00

171 lines
5.5 KiB
TypeScript

import {
CheckoutSession,
CreateCheckoutSession,
GetCheckoutSession,
GetWorkspacePlan,
SaveCheckoutSession,
UpdateCheckoutSessionStatus,
SaveWorkspaceSubscription,
UpsertPaidWorkspacePlan,
GetSubscriptionData,
GetWorkspaceCheckoutSession
} from '@/modules/gatekeeper/domain/billing'
import {
PaidWorkspacePlans,
WorkspacePlanBillingIntervals
} from '@/modules/gatekeeper/domain/workspacePricing'
import {
WorkspaceAlreadyPaidError,
WorkspaceCheckoutSessionInProgressError
} from '@/modules/gatekeeper/errors/billing'
import { CountWorkspaceRoleWithOptionalProjectRole } from '@/modules/workspaces/domain/operations'
import { Roles, throwUncoveredError } from '@speckle/shared'
export const startCheckoutSessionFactory =
({
getWorkspaceCheckoutSession,
getWorkspacePlan,
countRole,
createCheckoutSession,
saveCheckoutSession
}: {
getWorkspaceCheckoutSession: GetWorkspaceCheckoutSession
getWorkspacePlan: GetWorkspacePlan
countRole: CountWorkspaceRoleWithOptionalProjectRole
createCheckoutSession: CreateCheckoutSession
saveCheckoutSession: SaveCheckoutSession
}) =>
async ({
workspaceId,
workspaceSlug,
workspacePlan,
billingInterval
}: {
workspaceId: string
workspaceSlug: string
workspacePlan: PaidWorkspacePlans
billingInterval: WorkspacePlanBillingIntervals
}): Promise<CheckoutSession> => {
// get workspace plan, if we're already on a paid plan, do not allow checkout
// paid plans should use a subscription modification
const existingWorkspacePlan = await getWorkspacePlan({ workspaceId })
if (existingWorkspacePlan) {
// maybe we can just ignore the plan not existing, cause we're putting it on a plan post checkout
switch (existingWorkspacePlan.status) {
// valid and paymentFailed, but not cancelled status is not something we need a checkout for
// we already have their credit card info
case 'valid':
case 'paymentFailed':
throw new WorkspaceAlreadyPaidError()
case 'cancelled':
// maybe, we can reactivate cancelled plans via the sub in stripe, but this is fine too
// it will create a new customer and a new sub though, the reactivation would use the existing customer
case 'trial':
// lets go ahead and pay
break
default:
throwUncoveredError(existingWorkspacePlan)
}
}
// if there is already a checkout session for the workspace, stop, someone else is maybe trying to pay for the workspace
const workspaceCheckoutSession = await getWorkspaceCheckoutSession({ workspaceId })
if (workspaceCheckoutSession) throw new WorkspaceCheckoutSessionInProgressError()
const [adminCount, memberCount, guestCount] = await Promise.all([
countRole({ workspaceId, workspaceRole: Roles.Workspace.Admin }),
countRole({ workspaceId, workspaceRole: Roles.Workspace.Member }),
countRole({ workspaceId, workspaceRole: Roles.Workspace.Guest })
])
const checkoutSession = await createCheckoutSession({
workspaceId,
workspaceSlug,
billingInterval,
workspacePlan,
guestCount,
seatCount: adminCount + memberCount
})
await saveCheckoutSession({ checkoutSession })
return checkoutSession
}
export const completeCheckoutSessionFactory =
({
getCheckoutSession,
updateCheckoutSessionStatus,
saveWorkspaceSubscription,
upsertPaidWorkspacePlan,
getSubscriptionData
}: {
getCheckoutSession: GetCheckoutSession
updateCheckoutSessionStatus: UpdateCheckoutSessionStatus
saveWorkspaceSubscription: SaveWorkspaceSubscription
upsertPaidWorkspacePlan: UpsertPaidWorkspacePlan
getSubscriptionData: GetSubscriptionData
}) =>
/**
* Complete a paid checkout session
*/
async ({
sessionId,
subscriptionId
}: {
sessionId: string
subscriptionId: string
}): Promise<void> => {
const checkoutSession = await getCheckoutSession({ sessionId })
if (!checkoutSession)
throw new Error('checkout session is not found this is a bo bo')
switch (checkoutSession.paymentStatus) {
case 'paid':
// if the session is already paid, we do not need to provision anything
return
case 'unpaid':
break
default:
throwUncoveredError(checkoutSession.paymentStatus)
}
// TODO: make sure, the subscription data price plan matches the checkout session workspacePlan
await updateCheckoutSessionStatus({ sessionId, paymentStatus: 'paid' })
// a plan determines the workspace feature set
await upsertPaidWorkspacePlan({
workspacePlan: {
workspaceId: checkoutSession.workspaceId,
name: checkoutSession.workspacePlan,
status: 'valid'
}
})
const subscriptionData = await getSubscriptionData({
subscriptionId
})
const currentBillingCycleEnd = new Date()
switch (checkoutSession.billingInterval) {
case 'monthly':
currentBillingCycleEnd.setMonth(currentBillingCycleEnd.getMonth() + 1)
break
case 'yearly':
currentBillingCycleEnd.setMonth(currentBillingCycleEnd.getMonth() + 12)
break
default:
throwUncoveredError(checkoutSession.billingInterval)
}
const workspaceSubscription = {
createdAt: new Date(),
currentBillingCycleEnd,
workspaceId: checkoutSession.workspaceId,
billingInterval: checkoutSession.billingInterval,
subscriptionData
}
await saveWorkspaceSubscription({
workspaceSubscription
})
}