Files
speckle-server/packages/frontend-2/lib/billing/composables/actions.ts
T
2025-07-15 12:19:46 +02:00

262 lines
7.3 KiB
TypeScript

import { useApolloClient, useMutation } from '@vue/apollo-composable'
import { settingsWorkspaceBillingCustomerPortalQuery } from '~/lib/settings/graphql/queries'
import {
billingCreateCheckoutSessionMutation,
billingUpgradePlanMuation
} from '~/lib/billing/graphql/mutations'
import {
type PaidWorkspacePlans,
BillingInterval,
type BillingActions_WorkspaceFragment,
WorkspacePlanStatuses
} from '~/lib/common/generated/gql/graphql'
import { settingsBillingCancelCheckoutSessionMutation } from '~/lib/settings/graphql/mutations'
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
import { useMixpanel } from '~/lib/core/composables/mp'
import { graphql } from '~~/lib/common/generated/gql'
import type { MaybeNullOrUndefined } from '@speckle/shared'
import { formatName } from '~/lib/billing/helpers/plan'
graphql(`
fragment BillingActions_Workspace on Workspace {
id
name
invitedTeam(filter: $invitesFilter) {
id
}
plan {
name
status
}
subscription {
billingInterval
}
team {
totalCount
}
defaultRegion {
name
}
}
`)
export const useBillingActions = () => {
const mixpanel = useMixpanel()
const route = useRoute()
const router = useRouter()
const { triggerNotification } = useGlobalToast()
const { client: apollo } = useApolloClient()
const { mutate: cancelCheckoutSessionMutation } = useMutation(
settingsBillingCancelCheckoutSessionMutation
)
const logger = useLogger()
const { $intercom } = useNuxtApp()
const billingPortalRedirect = async (workspaceId: MaybeNullOrUndefined<string>) => {
if (!workspaceId) {
logger.error('[Billing Portal] No workspaceId provided, returning early')
return
}
mixpanel.track('Workspace Billing Portal Button Clicked', {
// eslint-disable-next-line camelcase
workspace_id: workspaceId
})
// We need to fetch this on click because the link expires very quickly
const result = await apollo.query({
query: settingsWorkspaceBillingCustomerPortalQuery,
variables: { workspaceId },
fetchPolicy: 'no-cache'
})
if (result.data?.workspace.customerPortalUrl) {
window.open(result.data.workspace.customerPortalUrl, '_blank')
} else {
logger.warn(
'[Billing Portal] No portal URL returned, full response:',
result.data
)
}
}
const redirectToCheckout = async (args: {
plan: PaidWorkspacePlans
cycle: BillingInterval
workspaceId: string
isCreateFlow?: boolean
}) => {
const { plan, cycle, workspaceId, isCreateFlow } = args
const result = await apollo
.mutate({
mutation: billingCreateCheckoutSessionMutation,
variables: {
input: {
workspaceId,
billingInterval: cycle,
workspacePlan: plan,
isCreateFlow: !!isCreateFlow
}
},
fetchPolicy: 'no-cache'
})
.catch(convertThrowIntoFetchResult)
if (result.data?.workspaceMutations.billing.createCheckoutSession) {
window.location.href =
result.data.workspaceMutations.billing.createCheckoutSession.url
} else {
const errMsg = getFirstGqlErrorMessage(result?.errors)
triggerNotification({
type: ToastNotificationType.Danger,
description: errMsg
})
}
}
const upgradePlan = async (args: {
plan: PaidWorkspacePlans
cycle: BillingInterval
workspaceId: string
}) => {
const { plan, cycle, workspaceId } = args
const result = await apollo
.mutate({
mutation: billingUpgradePlanMuation,
variables: {
input: {
workspaceId,
billingInterval: cycle,
workspacePlan: plan
}
},
update: (cache, res) => {
const { data } = res
if (!data?.workspaceMutations) return
cache.modify({
id: getCacheId('Workspace', workspaceId),
fields: {
plan: () => {
return {
name: plan,
status: WorkspacePlanStatuses.Valid
}
},
subscription: () => {
return {
billingInterval: cycle
}
}
}
})
}
})
.catch(convertThrowIntoFetchResult)
if (result.data) {
const metaData = {
plan,
cycle,
// eslint-disable-next-line camelcase
workspace_id: workspaceId
}
$intercom.track('Workspace Upgraded', {
...metaData,
isExistingSubscription: false
})
$intercom.updateCompany({
id: workspaceId,
/* eslint-disable camelcase */
plan_name: plan,
plan_status: WorkspacePlanStatuses.Valid
/* eslint-enable camelcase */
})
triggerNotification({
type: ToastNotificationType.Success,
title: 'Workspace plan upgraded',
description: `Your workspace is now on ${
cycle === BillingInterval.Yearly ? 'an annual' : 'a monthly'
} ${formatName(plan)} plan`
})
} else {
const errMsg = getFirstGqlErrorMessage(result?.errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: errMsg
})
}
}
const cancelCheckoutSession = async (sessionId: string, workspaceId: string) => {
await cancelCheckoutSessionMutation({
input: { sessionId, workspaceId }
})
mixpanel.track('Checkout Session Cancelled', {
// eslint-disable-next-line camelcase
workspace_id: workspaceId
})
}
const validateCheckoutSession = (workspace: BillingActions_WorkspaceFragment) => {
const sessionIdQuery = route.query?.session_id
const paymentStatusQuery = route.query?.payment_status
if (sessionIdQuery && paymentStatusQuery) {
if (paymentStatusQuery === WorkspacePlanStatuses.Canceled) {
cancelCheckoutSession(String(sessionIdQuery), workspace.id)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Your payment was canceled'
})
mixpanel.track('Workspace Upgrade Cancelled', {
// eslint-disable-next-line camelcase
workspace_id: workspace.id
})
} else {
triggerNotification({
type: ToastNotificationType.Success,
title: 'Your workspace plan was successfully updated'
})
const metaData = {
plan: workspace.plan?.name,
cycle: workspace.subscription?.billingInterval,
// eslint-disable-next-line camelcase
workspace_id: workspace.id
}
$intercom.track('Workspace Upgraded', {
...metaData,
isExistingSubscription: false
})
$intercom.updateCompany({
id: workspace.id,
/* eslint-disable camelcase */
plan_name: workspace.plan?.name,
plan_status: workspace.plan?.status
/* eslint-enable camelcase */
})
}
const currentQueryParams = { ...route.query }
delete currentQueryParams.session_id
delete currentQueryParams.payment_status
router.push({ query: currentQueryParams })
}
}
return {
billingPortalRedirect,
redirectToCheckout,
cancelCheckoutSession,
validateCheckoutSession,
upgradePlan
}
}