Fix: Various billing fixes (#3569)

This commit is contained in:
Mike
2024-11-28 20:24:05 +01:00
committed by GitHub
parent 562902d58b
commit b2cebea7eb
23 changed files with 575 additions and 125 deletions
@@ -23,6 +23,7 @@
</template>
<script setup lang="ts">
import dayjs from 'dayjs'
import {
ExclamationCircleIcon,
ArrowTopRightOnSquareIcon
@@ -41,6 +42,7 @@ graphql(`
plan {
name
status
createdAt
}
subscription {
billingInterval
@@ -63,9 +65,17 @@ const isTrial = computed(
const isPaymentFailed = computed(
() => planStatus.value === WorkspacePlanStatuses.PaymentFailed
)
const trialDaysLeft = computed(() => {
const createdAt = props.workspace.plan?.createdAt
const trialEndDate = dayjs(createdAt).add(31, 'days')
const diffDays = trialEndDate.diff(dayjs(), 'day')
return Math.max(0, diffDays)
})
const title = computed(() => {
if (isTrial.value) {
return `You are currently on a free ${
return `You have ${trialDaysLeft.value} day${
trialDaysLeft.value !== 1 ? 's' : ''
} left on your free ${
props.workspace.plan?.name ?? WorkspacePlans.Starter
} plan trial`
}
@@ -84,7 +94,9 @@ const title = computed(() => {
})
const description = computed(() => {
if (isTrial.value) {
return 'Upgrade to a paid plan to start your subscription.'
return trialDaysLeft.value === 0
? 'Upgrade to a paid plan to continue using your workspace.'
: 'Upgrade to a paid plan to start your subscription.'
}
switch (planStatus.value) {
case WorkspacePlanStatuses.CancelationScheduled:
@@ -84,7 +84,7 @@
:tag="
item.plan?.status === WorkspacePlanStatuses.Trial ||
!item.plan?.status
? 'Trial'
? 'TRIAL'
: undefined
"
class="!pl-1"
@@ -10,9 +10,5 @@
d="M8 1C6.24288 1 4.81818 2.42334 4.81818 4.18004C4.81818 5.93674 6.24288 7.36008 8 7.36008C9.75712 7.36008 11.1818 5.93674 11.1818 4.18004C11.1818 2.42334 9.75712 1 8 1Z"
fill="currentColor"
/>
<path
d="M6.18182 9.17649C4.42465 9.17649 3 10.6005 3 12.3578V14.6281H13V12.3578C13 10.6005 11.5754 9.17649 9.81818 9.17649H6.18182Z"
fill="currentColor"
/>
</svg>
</template>
@@ -0,0 +1,16 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12.8849 5.91851L7.25614 11.74L3.99951 8.48337L4.93388 7.549L7.24028 9.8554L11.9349 5L12.8849 5.91851Z"
fill="currentColor"
/>
</svg>
</template>
@@ -51,7 +51,7 @@
:tag="
workspaceItem.plan?.status === WorkspacePlanStatuses.Trial ||
!workspaceItem.plan?.status
? 'Trial'
? 'TRIAL'
: undefined
"
:collapsed="targetWorkspaceId !== workspaceItem.id"
@@ -14,17 +14,25 @@
<SettingsSectionHeader title="Billing summary" subheading class="pt-4" />
<div class="border border-outline-3 rounded-lg">
<div
class="grid grid-cols-1 md:grid-cols-3 divide-y divide-outline-3 md:divide-y-0 md:divide-x"
class="grid grid-cols-1 lg:grid-cols-3 divide-y divide-outline-3 lg:divide-y-0 lg:divide-x"
>
<div class="p-5 pt-4 flex flex-col gap-y-1">
<h3 class="text-body-xs text-foreground-2 pb-2">
{{ isTrialPeriod ? 'Trial plan' : 'Current plan' }}
</h3>
<p class="text-heading-lg text-foreground capitalize">
{{ currentPlan?.name ?? WorkspacePlans.Starter }} plan
</p>
<div class="flex gap-x-2">
<p class="text-heading-lg text-foreground">
Workspace
<span class="capitalize">
{{ currentPlan?.name ?? WorkspacePlans.Starter }}
</span>
</p>
<div>
<CommonBadge v-if="isTrialPeriod" rounded>TRIAL</CommonBadge>
</div>
</div>
<p v-if="isPurchasablePlan" class="text-body-xs text-foreground-2">
£{{ seatPrice }} per seat/month, billed
£{{ seatPrice[Roles.Workspace.Member] }} per seat/month, billed
{{
subscription?.billingInterval === BillingInterval.Yearly
? 'yearly'
@@ -42,9 +50,26 @@
: 'Monthly bill'
}}
</h3>
<p class="text-heading-lg text-foreground capitalize">
{{ isPurchasablePlan ? 'Coming soon' : 'Not applicable' }}
</p>
<template v-if="isTrialPeriod">
<p class="text-heading-lg text-foreground inline-block">
{{ billValue }}
</p>
<p class="text-body-xs text-foreground-2 flex gap-x-1 items-center">
{{ billDescription }}
<InformationCircleIcon
v-tippy="billTooltip"
class="w-4 h-4 text-foreground cursor-pointer"
/>
</p>
</template>
<div v-else>
<button
class="text-heading-lg text-foreground"
@click="billingPortalRedirect(workspaceId)"
>
View on Stripe &#8599;
</button>
</div>
</div>
<div class="p-5 pt-4 flex flex-col gap-y-1">
<h3 class="text-body-xs text-foreground-2 pb-2">
@@ -55,7 +80,7 @@
}}
</h3>
<p class="text-heading-lg text-foreground capitalize">
{{ isPurchasablePlan ? nextPaymentDue : 'Not applicable' }}
{{ isPurchasablePlan ? nextPaymentDue : 'Never' }}
</p>
<p v-if="isPurchasablePlan" class="text-body-xs text-foreground-2">
<span class="capitalize">
@@ -89,6 +114,7 @@
class="pt-6"
:workspace-id="workspaceId"
:current-plan="currentPlan"
:active-billing-interval="subscription?.billingInterval"
:is-admin="isAdmin"
>
<template #title>
@@ -119,6 +145,7 @@ import {
import { useBillingActions } from '~/lib/billing/composables/actions'
import { pricingPlansConfig } from '~/lib/billing/helpers/constants'
import { Roles } from '@speckle/shared'
import { InformationCircleIcon } from '@heroicons/vue/24/outline'
graphql(`
fragment SettingsWorkspacesBilling_Workspace on Workspace {
@@ -134,6 +161,12 @@ graphql(`
billingInterval
currentBillingCycleEnd
}
team {
items {
id
role
}
}
}
`)
@@ -159,8 +192,9 @@ const seatPrices = ref({
[WorkspacePlans.Business]: pricingPlansConfig.plans[WorkspacePlans.Business].cost
})
const currentPlan = computed(() => workspaceResult.value?.workspace.plan)
const subscription = computed(() => workspaceResult.value?.workspace.subscription)
const workspace = computed(() => workspaceResult.value?.workspace)
const currentPlan = computed(() => workspace.value?.plan)
const subscription = computed(() => workspace.value?.subscription)
const isTrialPeriod = computed(
() =>
currentPlan.value?.status === WorkspacePlanStatuses.Trial ||
@@ -183,10 +217,8 @@ const seatPrice = computed(() =>
currentPlan.value && subscription.value
? seatPrices.value[currentPlan.value.name as keyof typeof seatPrices.value][
subscription.value.billingInterval
][Roles.Workspace.Member]
: seatPrices.value[WorkspacePlans.Starter][BillingInterval.Monthly][
Roles.Workspace.Member
]
: seatPrices.value[WorkspacePlans.Starter][BillingInterval.Monthly]
)
const nextPaymentDue = computed(() =>
currentPlan.value
@@ -195,7 +227,39 @@ const nextPaymentDue = computed(() =>
: 'Never'
: dayjs().add(30, 'days').format('MMMM D, YYYY')
)
const isAdmin = computed(
() => workspaceResult.value?.workspace.role === Roles.Workspace.Admin
const isAdmin = computed(() => workspace.value?.role === Roles.Workspace.Admin)
const guestSeatCount = computed(() =>
workspace.value
? workspace.value.team.items.filter((user) => user.role === Roles.Workspace.Guest)
.length
: 0
)
const memberSeatCount = computed(() =>
workspace.value ? workspace.value.team.items.length - guestSeatCount.value : 0
)
const billValue = computed(() => {
const guestPrice = seatPrice.value[Roles.Workspace.Guest] * guestSeatCount.value
const memberPrice = seatPrice.value[Roles.Workspace.Member] * memberSeatCount.value
const totalPrice = guestPrice + memberPrice
if (isTrialPeriod.value) return `£${totalPrice}.00`
return `£0.00`
})
const billDescription = computed(() => {
const memberText =
memberSeatCount.value > 1 ? `${memberSeatCount.value} members` : '1 member'
const guestText =
guestSeatCount.value > 1 ? `${guestSeatCount.value} guests` : '1 guest'
return `${memberText}${guestSeatCount.value > 0 ? `, ${guestText}` : ''}`
})
const billTooltip = computed(() => {
const memberText = `${memberSeatCount.value} member${
memberSeatCount.value === 1 ? '' : 's'
} £${seatPrice.value[Roles.Workspace.Member]}`
const guestText = `${guestSeatCount.value} guest${
guestSeatCount.value === 1 ? '' : 's'
} £${seatPrice.value[Roles.Workspace.Guest]}`
return `${memberText}${guestSeatCount.value > 0 ? `, ${guestText}` : ''}`
})
</script>
@@ -16,13 +16,7 @@
]"
scope="col"
>
<SettingsWorkspacesBillingPricingTableHeader
:plan="plan"
:is-yearly-plan="isYearlyPlan"
:current-plan="currentPlan"
:workspace-id="workspaceId"
:is-admin="isAdmin"
/>
<SettingsWorkspacesBillingPricingTableHeader :plan="plan" v-bind="$props" />
</th>
</tr>
</thead>
@@ -51,9 +45,9 @@
]"
>
<div class="border-b border-outline-3 flex items-center px-3 min-h-[42px]">
<CheckIcon
<IconCheck
v-if="plan.features.includes(feature.name as PlanFeaturesList)"
class="w-3 h-3 text-foreground"
class="w-4 h-4 text-foreground"
/>
</div>
</td>
@@ -63,11 +57,10 @@
</template>
<script setup lang="ts">
import type { WorkspacePlan } from '~/lib/common/generated/gql/graphql'
import type { WorkspacePlan, BillingInterval } from '~/lib/common/generated/gql/graphql'
import { WorkspacePlans } from '~/lib/common/generated/gql/graphql'
import { pricingPlansConfig } from '~/lib/billing/helpers/constants'
import type { PlanFeaturesList } from '~/lib/billing/helpers/types'
import { CheckIcon } from '@heroicons/vue/24/outline'
import type { MaybeNullOrUndefined } from '@speckle/shared'
defineProps<{
@@ -75,6 +68,7 @@ defineProps<{
currentPlan?: MaybeNullOrUndefined<WorkspacePlan>
workspaceId?: string
isAdmin?: boolean
activeBillingInterval?: BillingInterval
}>()
const plans = ref(pricingPlansConfig.plans)
@@ -17,14 +17,13 @@
</p>
<div v-if="workspaceId" class="w-full">
<FormButton
:color="plan.name === WorkspacePlans.Starter ? 'primary' : 'outline'"
:disabled="(!hasTrialPlan && !canUpgradeToPlan) || !isAdmin"
:color="buttonColor"
:disabled="!buttonEnabled"
class="mt-3"
full-width
@click="onUpgradePlanClick(plan.name)"
>
{{ hasTrialPlan ? 'Subscribe' : 'Upgrade' }} to&nbsp;
<span class="capitalize">{{ plan.name }}</span>
{{ buttonText }}
</FormButton>
</div>
</div>
@@ -42,6 +41,7 @@ import {
} from '~/lib/common/generated/gql/graphql'
import { useBillingActions } from '~/lib/billing/composables/actions'
import type { MaybeNullOrUndefined } from '@speckle/shared'
import { startCase } from 'lodash'
const props = defineProps<{
plan: PricingPlan
@@ -50,9 +50,10 @@ const props = defineProps<{
currentPlan?: MaybeNullOrUndefined<WorkspacePlan>
workspaceId?: string
isAdmin?: boolean
activeBillingInterval?: BillingInterval
}>()
const { upgradePlanRedirect } = useBillingActions()
const { redirectToCheckout, upgradePlan } = useBillingActions()
const canUpgradeToPlan = computed(() => {
if (!props.currentPlan) return false
@@ -70,13 +71,74 @@ const canUpgradeToPlan = computed(() => {
const hasTrialPlan = computed(
() => props.currentPlan?.status === WorkspacePlanStatuses.Trial || !props.currentPlan
)
const buttonColor = computed(() => {
// If on trial plan highlight starter plan
if (hasTrialPlan.value) {
return props.plan.name === WorkspacePlans.Starter ? 'primary' : 'outline'
}
// Else highlight current plan
return props.currentPlan?.name === props.plan.name ? 'primary' : 'outline'
})
const isMatchingInterval = computed(
() =>
props.activeBillingInterval ===
(props.isYearlyPlan ? BillingInterval.Yearly : BillingInterval.Monthly)
)
const buttonEnabled = computed(() => {
// Always enable buttons during trial
if (hasTrialPlan.value) return true
// Disable if user is already on this plan with same billing interval
if (isMatchingInterval.value && props.currentPlan?.name === props.plan.name)
return false
// Handle billing interval changes
if (!isMatchingInterval.value) {
const isCurrentPlan = props.currentPlan?.name === props.plan.name
const isMonthlyToYearly =
props.isYearlyPlan && props.activeBillingInterval === BillingInterval.Monthly
// Allow yearly upgrades from monthly plans
if (isMonthlyToYearly) return isCurrentPlan || canUpgradeToPlan.value
// Never allow switching to monthly if currently on yearly billing
if (props.activeBillingInterval === BillingInterval.Yearly) return false
// Allow monthly plan changes only for upgrades
return canUpgradeToPlan.value
}
// Allow upgrades to higher tier plans
return canUpgradeToPlan.value
})
const buttonText = computed(() => {
// Trial plan case
if (hasTrialPlan.value) {
return `Subscribe to ${startCase(props.plan.name)}`
}
// Current plan case
if (isMatchingInterval.value && props.currentPlan?.name === props.plan.name) {
return 'Current plan'
}
// Billing interval change case
if (!isMatchingInterval.value || !canUpgradeToPlan.value) {
return props.isYearlyPlan ? 'Change to annual plan' : 'Change to monthly plan'
}
// Upgrade case
return canUpgradeToPlan.value ? `Upgrade to ${startCase(props.plan.name)}` : ''
})
const onUpgradePlanClick = (plan: WorkspacePlans) => {
if (!isPaidPlan(plan) || !props.workspaceId) return
upgradePlanRedirect({
plan: plan as unknown as PaidWorkspacePlans,
cycle: props.isYearlyPlan ? BillingInterval.Yearly : BillingInterval.Monthly,
workspaceId: props.workspaceId
})
if (hasTrialPlan.value) {
redirectToCheckout({
plan: plan as unknown as PaidWorkspacePlans,
cycle: props.isYearlyPlan ? BillingInterval.Yearly : BillingInterval.Monthly,
workspaceId: props.workspaceId
})
} else {
upgradePlan({
plan: plan as unknown as PaidWorkspacePlans,
cycle: props.isYearlyPlan ? BillingInterval.Yearly : BillingInterval.Monthly,
workspaceId: props.workspaceId
})
}
}
</script>
@@ -5,13 +5,7 @@
:key="`mobile-${plan.name}`"
class="border border-outline-3 bg-foundation rounded-lg p-4 pb-2"
>
<SettingsWorkspacesBillingPricingTableHeader
:plan="plan"
:is-yearly-plan="isYearlyPlan"
:current-plan="currentPlan"
:workspace-id="workspaceId"
:is-admin="isAdmin"
/>
<SettingsWorkspacesBillingPricingTableHeader :plan="plan" v-bind="$props" />
<ul class="flex flex-col gap-y-2 mt-6">
<li
v-for="feature in features"
@@ -19,10 +13,11 @@
class="flex items-center justify-between border-b last:border-b-0 border-outline-3 pb-2"
>
{{ feature.name }}
<CheckIcon
<IconCheck
v-if="plan.features.includes(feature.name as PlanFeaturesList)"
class="w-3 h-3 text-foreground"
class="w-4 h-4 text-foreground"
/>
<XMarkIcon v-else class="w-4 h-4 text-foreground-2" />
</li>
</ul>
</div>
@@ -30,10 +25,10 @@
</template>
<script setup lang="ts">
import type { WorkspacePlan } from '~/lib/common/generated/gql/graphql'
import type { WorkspacePlan, BillingInterval } from '~/lib/common/generated/gql/graphql'
import { pricingPlansConfig } from '~/lib/billing/helpers/constants'
import type { PlanFeaturesList } from '~/lib/billing/helpers/types'
import { CheckIcon } from '@heroicons/vue/24/outline'
import { XMarkIcon } from '@heroicons/vue/24/outline'
import type { MaybeNullOrUndefined } from '@speckle/shared'
defineProps<{
@@ -41,6 +36,7 @@ defineProps<{
currentPlan: MaybeNullOrUndefined<WorkspacePlan>
workspaceId: string
isAdmin: boolean
activeBillingInterval?: BillingInterval
}>()
const plans = ref(pricingPlansConfig.plans)
@@ -4,15 +4,18 @@
<slot name="title" />
<div class="flex items-center gap-x-4">
<p class="text-foreground-3 text-body-xs">Save 20% with annual billing</p>
<FormSwitch v-model="isYearlyPlan" :show-label="false" name="annual billing" />
<FormSwitch
v-model="isYearlyPlan"
:disabled="activeBillingInterval === BillingInterval.Yearly"
:show-label="false"
name="annual billing"
/>
</div>
</div>
<component
:is="isDesktop ? DesktopTable : MobileTable"
:workspace-id="workspaceId"
:current-plan="currentPlan"
:is-yearly-plan="isYearlyPlan"
:is-admin="isAdmin"
v-bind="$props"
/>
</div>
</template>
@@ -20,7 +23,7 @@
<script setup lang="ts">
import { useBreakpoints } from '@vueuse/core'
import { TailwindBreakpoints } from '~~/lib/common/helpers/tailwind'
import { type WorkspacePlan } from '~/lib/common/generated/gql/graphql'
import { type WorkspacePlan, BillingInterval } from '~/lib/common/generated/gql/graphql'
import { graphql } from '~/lib/common/generated/gql'
import type { MaybeNullOrUndefined } from '@speckle/shared'
@@ -31,9 +34,10 @@ graphql(`
}
`)
defineProps<{
const props = defineProps<{
workspaceId?: string
currentPlan?: MaybeNullOrUndefined<WorkspacePlan>
activeBillingInterval?: BillingInterval
isAdmin?: boolean
}>()
@@ -47,4 +51,12 @@ const MobileTable = defineAsyncComponent(
)
const isDesktop = breakpoints.greaterOrEqual('lg')
const isYearlyPlan = ref(false)
watch(
() => props.activeBillingInterval,
(newVal) => {
isYearlyPlan.value = newVal === BillingInterval.Yearly
},
{ immediate: true }
)
</script>
@@ -1,9 +1,9 @@
<template>
<div>
<BillingAlert :workspace="workspaceInfo" class="mb-4">
<BillingAlert v-if="!isWorkspaceGuest" :workspace="workspaceInfo" class="mb-4">
<template #actions>
<FormButton
v-if="isWorkspaceAdmin && isInTrial"
v-if="isInTrial"
@click="openSettingsDialog(SettingMenuKeys.Workspace.Billing)"
>
Upgrade now
@@ -1,8 +1,11 @@
import { useApolloClient, useMutation } from '@vue/apollo-composable'
import { settingsWorkspaceBillingCustomerPortalQuery } from '~/lib/settings/graphql/queries'
import { billingUpgradePlanRedirectMutation } from '~/lib/billing/graphql/mutations'
import type {
PaidWorkspacePlans,
import {
billingCreateCheckoutSessionMutation,
billingUpgradePlanMuation
} from '~/lib/billing/graphql/mutations'
import {
type PaidWorkspacePlans,
BillingInterval
} from '~/lib/common/generated/gql/graphql'
import { settingsBillingCancelCheckoutSessionMutation } from '~/lib/settings/graphql/mutations'
@@ -21,7 +24,7 @@ export const useBillingActions = () => {
)
const billingPortalRedirect = async (workspaceId: string) => {
mixpanel.track('Billing Portal Button Clicked', {
mixpanel.track('Workspace Billing Portal Button Clicked', {
// eslint-disable-next-line camelcase
workspace_id: workspaceId
})
@@ -38,13 +41,13 @@ export const useBillingActions = () => {
}
}
const upgradePlanRedirect = async (args: {
const redirectToCheckout = async (args: {
plan: PaidWorkspacePlans
cycle: BillingInterval
workspaceId: string
}) => {
const { plan, cycle, workspaceId } = args
mixpanel.track('Upgrade Button Clicked', {
mixpanel.track('Workspace Subscribe Button Clicked', {
plan,
cycle,
// eslint-disable-next-line camelcase
@@ -53,7 +56,7 @@ export const useBillingActions = () => {
const result = await apollo
.mutate({
mutation: billingUpgradePlanRedirectMutation,
mutation: billingCreateCheckoutSessionMutation,
variables: {
input: {
workspaceId,
@@ -77,6 +80,56 @@ export const useBillingActions = () => {
}
}
const upgradePlan = async (args: {
plan: PaidWorkspacePlans
cycle: BillingInterval
workspaceId: string
}) => {
const { plan, cycle, workspaceId } = args
mixpanel.track('Workspace Upgrade Button Clicked', {
plan,
cycle,
// eslint-disable-next-line camelcase
workspace_id: workspaceId
})
const result = await apollo
.mutate({
mutation: billingUpgradePlanMuation,
variables: {
input: {
workspaceId,
billingInterval: cycle,
workspacePlan: plan
}
}
})
.catch(convertThrowIntoFetchResult)
if (result.data) {
mixpanel.track('Workspace Upgraded', {
plan,
cycle,
// eslint-disable-next-line camelcase
workspace_id: workspaceId
})
triggerNotification({
type: ToastNotificationType.Success,
title: 'Workspace plan upgraded',
description: `Your workspace is now on a ${
cycle === BillingInterval.Yearly ? 'annual' : 'monthly'
} ${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 }
@@ -115,8 +168,9 @@ export const useBillingActions = () => {
return {
billingPortalRedirect,
upgradePlanRedirect,
redirectToCheckout,
cancelCheckoutSession,
validateCheckoutSession
validateCheckoutSession,
upgradePlan
}
}
@@ -1,7 +1,7 @@
import { graphql } from '~~/lib/common/generated/gql'
export const billingUpgradePlanRedirectMutation = graphql(`
mutation BillingUpgradePlanRedirect($input: CheckoutSessionInput!) {
export const billingCreateCheckoutSessionMutation = graphql(`
mutation BillingCreateCheckoutSession($input: CheckoutSessionInput!) {
workspaceMutations {
billing {
createCheckoutSession(input: $input) {
@@ -12,3 +12,13 @@ export const billingUpgradePlanRedirectMutation = graphql(`
}
}
`)
export const billingUpgradePlanMuation = graphql(`
mutation BillingUpgradePlan($input: UpgradePlanInput!) {
workspaceMutations {
billing {
upgradePlan(input: $input)
}
}
}
`)
@@ -40,7 +40,7 @@ const documents = {
"\n fragment AutomateRunsTriggerStatusDialogRunsRows_AutomateRun on AutomateRun {\n id\n functionRuns {\n id\n ...AutomateRunsTriggerStatusDialogFunctionRun_AutomateFunctionRun\n }\n ...AutomationsStatusOrderedRuns_AutomationRun\n }\n": types.AutomateRunsTriggerStatusDialogRunsRows_AutomateRunFragmentDoc,
"\n fragment AutomateViewerPanel_AutomateRun on AutomateRun {\n id\n functionRuns {\n id\n ...AutomateViewerPanelFunctionRunRow_AutomateFunctionRun\n }\n ...AutomationsStatusOrderedRuns_AutomationRun\n }\n": types.AutomateViewerPanel_AutomateRunFragmentDoc,
"\n fragment AutomateViewerPanelFunctionRunRow_AutomateFunctionRun on AutomateFunctionRun {\n id\n results\n status\n statusMessage\n contextView\n function {\n id\n logo\n name\n }\n createdAt\n updatedAt\n }\n": types.AutomateViewerPanelFunctionRunRow_AutomateFunctionRunFragmentDoc,
"\n fragment BillingAlert_Workspace on Workspace {\n id\n plan {\n name\n status\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n": types.BillingAlert_WorkspaceFragmentDoc,
"\n fragment BillingAlert_Workspace on Workspace {\n id\n plan {\n name\n status\n createdAt\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n": types.BillingAlert_WorkspaceFragmentDoc,
"\n fragment CommonModelSelectorModel on Model {\n id\n name\n }\n": types.CommonModelSelectorModelFragmentDoc,
"\n fragment DashboardProjectCard_Project on Project {\n id\n name\n role\n updatedAt\n models {\n totalCount\n }\n team {\n user {\n ...LimitedUserAvatar\n }\n }\n workspace {\n id\n slug\n name\n ...WorkspaceAvatar_Workspace\n }\n }\n": types.DashboardProjectCard_ProjectFragmentDoc,
"\n fragment FormSelectModels_Model on Model {\n id\n name\n }\n": types.FormSelectModels_ModelFragmentDoc,
@@ -113,7 +113,7 @@ const documents = {
"\n fragment SettingsUserProfileDeleteAccount_User on User {\n id\n email\n }\n": types.SettingsUserProfileDeleteAccount_UserFragmentDoc,
"\n fragment SettingsUserProfileDetails_User on User {\n id\n name\n company\n ...UserProfileEditDialogAvatar_User\n }\n": types.SettingsUserProfileDetails_UserFragmentDoc,
"\n fragment UserProfileEditDialogAvatar_User on User {\n id\n avatar\n ...ActiveUserAvatar\n }\n": types.UserProfileEditDialogAvatar_UserFragmentDoc,
"\n fragment SettingsWorkspacesBilling_Workspace on Workspace {\n ...BillingAlert_Workspace\n id\n role\n plan {\n ...SettingsWorkspacesBillingPricingTable_WorkspacePlan\n name\n status\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n": types.SettingsWorkspacesBilling_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesBilling_Workspace on Workspace {\n ...BillingAlert_Workspace\n id\n role\n plan {\n ...SettingsWorkspacesBillingPricingTable_WorkspacePlan\n name\n status\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n team {\n items {\n id\n role\n }\n }\n }\n": types.SettingsWorkspacesBilling_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesGeneral_Workspace on Workspace {\n ...SettingsWorkspacesGeneralEditAvatar_Workspace\n ...SettingsWorkspaceGeneralDeleteDialog_Workspace\n ...SettingsWorkspacesGeneralEditSlugDialog_Workspace\n id\n name\n slug\n description\n logo\n role\n defaultProjectRole\n }\n": types.SettingsWorkspacesGeneral_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspaceGeneralDeleteDialog_Workspace on Workspace {\n id\n name\n }\n": types.SettingsWorkspaceGeneralDeleteDialog_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesGeneralEditAvatar_Workspace on Workspace {\n id\n logo\n name\n defaultLogoIndex\n }\n": types.SettingsWorkspacesGeneralEditAvatar_WorkspaceFragmentDoc,
@@ -167,7 +167,8 @@ const documents = {
"\n query FunctionAccessCheck($id: ID!) {\n automateFunction(id: $id) {\n id\n }\n }\n": types.FunctionAccessCheckDocument,
"\n query ProjectAutomationCreationPublicKeys(\n $projectId: String!\n $automationId: String!\n ) {\n project(id: $projectId) {\n id\n automation(id: $automationId) {\n id\n creationPublicKeys\n }\n }\n }\n": types.ProjectAutomationCreationPublicKeysDocument,
"\n query AutomateFunctionsPagePagination($search: String, $cursor: String) {\n ...AutomateFunctionsPageItems_Query\n }\n": types.AutomateFunctionsPagePaginationDocument,
"\n mutation BillingUpgradePlanRedirect($input: CheckoutSessionInput!) {\n workspaceMutations {\n billing {\n createCheckoutSession(input: $input) {\n url\n id\n }\n }\n }\n }\n": types.BillingUpgradePlanRedirectDocument,
"\n mutation BillingCreateCheckoutSession($input: CheckoutSessionInput!) {\n workspaceMutations {\n billing {\n createCheckoutSession(input: $input) {\n url\n id\n }\n }\n }\n }\n": types.BillingCreateCheckoutSessionDocument,
"\n mutation BillingUpgradePlan($input: UpgradePlanInput!) {\n workspaceMutations {\n billing {\n upgradePlan(input: $input)\n }\n }\n }\n": types.BillingUpgradePlanDocument,
"\n query MentionsUserSearch($query: String!, $emailOnly: Boolean = false) {\n userSearch(\n query: $query\n limit: 5\n cursor: null\n archived: false\n emailOnly: $emailOnly\n ) {\n items {\n id\n name\n company\n }\n }\n }\n": types.MentionsUserSearchDocument,
"\n query UserSearch(\n $query: String!\n $limit: Int\n $cursor: String\n $archived: Boolean\n $workspaceId: String\n ) {\n userSearch(query: $query, limit: $limit, cursor: $cursor, archived: $archived) {\n cursor\n items {\n id\n name\n bio\n company\n avatar\n verified\n role\n workspaceDomainPolicyCompliant(workspaceId: $workspaceId)\n }\n }\n }\n": types.UserSearchDocument,
"\n query ServerInfoBlobSizeLimit {\n serverInfo {\n configuration {\n blobSizeLimitBytes\n }\n }\n }\n": types.ServerInfoBlobSizeLimitDocument,
@@ -484,7 +485,7 @@ export function graphql(source: "\n fragment AutomateViewerPanelFunctionRunRow_
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment BillingAlert_Workspace on Workspace {\n id\n plan {\n name\n status\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n"): (typeof documents)["\n fragment BillingAlert_Workspace on Workspace {\n id\n plan {\n name\n status\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n"];
export function graphql(source: "\n fragment BillingAlert_Workspace on Workspace {\n id\n plan {\n name\n status\n createdAt\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n"): (typeof documents)["\n fragment BillingAlert_Workspace on Workspace {\n id\n plan {\n name\n status\n createdAt\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -776,7 +777,7 @@ export function graphql(source: "\n fragment UserProfileEditDialogAvatar_User o
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment SettingsWorkspacesBilling_Workspace on Workspace {\n ...BillingAlert_Workspace\n id\n role\n plan {\n ...SettingsWorkspacesBillingPricingTable_WorkspacePlan\n name\n status\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesBilling_Workspace on Workspace {\n ...BillingAlert_Workspace\n id\n role\n plan {\n ...SettingsWorkspacesBillingPricingTable_WorkspacePlan\n name\n status\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n"];
export function graphql(source: "\n fragment SettingsWorkspacesBilling_Workspace on Workspace {\n ...BillingAlert_Workspace\n id\n role\n plan {\n ...SettingsWorkspacesBillingPricingTable_WorkspacePlan\n name\n status\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n team {\n items {\n id\n role\n }\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesBilling_Workspace on Workspace {\n ...BillingAlert_Workspace\n id\n role\n plan {\n ...SettingsWorkspacesBillingPricingTable_WorkspacePlan\n name\n status\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n team {\n items {\n id\n role\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -992,7 +993,11 @@ export function graphql(source: "\n query AutomateFunctionsPagePagination($sear
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation BillingUpgradePlanRedirect($input: CheckoutSessionInput!) {\n workspaceMutations {\n billing {\n createCheckoutSession(input: $input) {\n url\n id\n }\n }\n }\n }\n"): (typeof documents)["\n mutation BillingUpgradePlanRedirect($input: CheckoutSessionInput!) {\n workspaceMutations {\n billing {\n createCheckoutSession(input: $input) {\n url\n id\n }\n }\n }\n }\n"];
export function graphql(source: "\n mutation BillingCreateCheckoutSession($input: CheckoutSessionInput!) {\n workspaceMutations {\n billing {\n createCheckoutSession(input: $input) {\n url\n id\n }\n }\n }\n }\n"): (typeof documents)["\n mutation BillingCreateCheckoutSession($input: CheckoutSessionInput!) {\n workspaceMutations {\n billing {\n createCheckoutSession(input: $input) {\n url\n id\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation BillingUpgradePlan($input: UpgradePlanInput!) {\n workspaceMutations {\n billing {\n upgradePlan(input: $input)\n }\n }\n }\n"): (typeof documents)["\n mutation BillingUpgradePlan($input: UpgradePlanInput!) {\n workspaceMutations {\n billing {\n upgradePlan(input: $input)\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
File diff suppressed because one or more lines are too long
@@ -45,7 +45,8 @@ input CancelCheckoutSessionInput {
input UpgradePlanInput {
workspaceId: ID!
targetPlan: PaidWorkspacePlans!
workspacePlan: PaidWorkspacePlans!
billingInterval: BillingInterval!
}
type WorkspaceBillingMutations {
@@ -3559,8 +3559,9 @@ export type UpdateVersionInput = {
};
export type UpgradePlanInput = {
targetPlan: PaidWorkspacePlans;
billingInterval: BillingInterval;
workspaceId: Scalars['ID']['input'];
workspacePlan: PaidWorkspacePlans;
};
/**
@@ -3540,8 +3540,9 @@ export type UpdateVersionInput = {
};
export type UpgradePlanInput = {
targetPlan: PaidWorkspacePlans;
billingInterval: BillingInterval;
workspaceId: Scalars['ID']['input'];
workspacePlan: PaidWorkspacePlans;
};
/**
@@ -177,7 +177,7 @@ export const reconcileWorkspaceSubscriptionFactory =
// we're moving a product to a new price for ie upgrading to a yearly plan
} else if (existingProduct.priceId !== product.priceId) {
items.push({ quantity: product.quantity, price: product.priceId })
items.push({ id: product.subscriptionItemId, deleted: true })
items.push({ id: existingProduct.subscriptionItemId, deleted: true })
} else {
items.push({
quantity: product.quantity,
@@ -26,7 +26,8 @@ import {
getWorkspacePlanFactory,
getWorkspaceSubscriptionFactory,
saveCheckoutSessionFactory,
upsertPaidWorkspacePlanFactory
upsertPaidWorkspacePlanFactory,
upsertWorkspaceSubscriptionFactory
} from '@/modules/gatekeeper/repositories/billing'
import { canWorkspaceAccessFeatureFactory } from '@/modules/gatekeeper/services/featureAuthorization'
import { upgradeWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/services/subscriptions'
@@ -131,7 +132,7 @@ export = FF_GATEKEEPER_MODULE_ENABLED
return session
},
upgradePlan: async (parent, args, ctx) => {
const { workspaceId, targetPlan } = args.input
const { workspaceId, workspacePlan, billingInterval } = args.input
await authorizeResolver(
ctx.userId,
workspaceId,
@@ -139,16 +140,22 @@ export = FF_GATEKEEPER_MODULE_ENABLED
ctx.resourceAccessRules
)
const stripe = getStripeClient()
const countWorkspaceRole = countWorkspaceRoleWithOptionalProjectRoleFactory({
db
})
await upgradeWorkspaceSubscriptionFactory({
getWorkspacePlan: getWorkspacePlanFactory({ db }),
reconcileSubscriptionData: reconcileWorkspaceSubscriptionFactory({
stripe
}),
countWorkspaceRole,
getWorkspaceSubscription: getWorkspaceSubscriptionFactory({ db }),
getWorkspacePlanPrice,
getWorkspacePlanProductId,
upsertWorkspacePlan: upsertPaidWorkspacePlanFactory({ db })
})({ workspaceId, targetPlan })
upsertWorkspacePlan: upsertPaidWorkspacePlanFactory({ db }),
updateWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({ db })
})({ workspaceId, targetPlan: workspacePlan, billingInterval })
return true
}
}
@@ -16,6 +16,7 @@ import {
} from '@/modules/gatekeeper/domain/billing'
import {
PaidWorkspacePlans,
WorkspacePlanBillingIntervals,
WorkspacePricingPlans
} from '@/modules/gatekeeper/domain/workspacePricing'
import {
@@ -344,6 +345,8 @@ export const upgradeWorkspaceSubscriptionFactory =
getWorkspacePlanPrice,
getWorkspaceSubscription,
reconcileSubscriptionData,
updateWorkspaceSubscription,
countWorkspaceRole,
upsertWorkspacePlan
}: {
getWorkspacePlan: GetWorkspacePlan
@@ -351,14 +354,18 @@ export const upgradeWorkspaceSubscriptionFactory =
getWorkspacePlanPrice: GetWorkspacePlanPrice
getWorkspaceSubscription: GetWorkspaceSubscription
reconcileSubscriptionData: ReconcileSubscriptionData
updateWorkspaceSubscription: UpsertWorkspaceSubscription
countWorkspaceRole: CountWorkspaceRoleWithOptionalProjectRole
upsertWorkspacePlan: UpsertPaidWorkspacePlan
}) =>
async ({
workspaceId,
targetPlan
targetPlan,
billingInterval
}: {
workspaceId: string
targetPlan: PaidWorkspacePlans
billingInterval: WorkspacePlanBillingIntervals
}) => {
const workspacePlan = await getWorkspacePlan({ workspaceId })
@@ -397,18 +404,68 @@ export const upgradeWorkspaceSubscriptionFactory =
starter: 1
}
if (planOrder[workspacePlan.name] >= planOrder[targetPlan])
if (
planOrder[workspacePlan.name] === planOrder[targetPlan] &&
workspaceSubscription.billingInterval === billingInterval
)
throw new WorkspacePlanDowngradeError()
if (planOrder[workspacePlan.name] > planOrder[targetPlan])
throw new WorkspacePlanDowngradeError()
switch (billingInterval) {
case 'monthly':
if (workspaceSubscription.billingInterval === 'yearly')
throw new WorkspacePlanDowngradeError()
case 'yearly':
break
default:
throwUncoveredError(billingInterval)
}
const subscriptionData: SubscriptionDataInput = cloneDeep(
workspaceSubscription.subscriptionData
)
const product = subscriptionData.products.find(
(p) =>
p.productId === getWorkspacePlanProductId({ workspacePlan: workspacePlan.name })
)
if (!product) throw new WorkspacePlanMismatchError()
const seatCount = product.quantity
const [guestCount, memberCount, adminCount] = await Promise.all([
countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:guest' }),
countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:member' }),
countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:admin' })
])
workspaceSubscription.updatedAt = new Date()
if (workspaceSubscription.billingInterval !== billingInterval) {
workspaceSubscription.billingInterval = billingInterval
workspaceSubscription.currentBillingCycleEnd = calculateNewBillingCycleEnd({
workspaceSubscription
})
const guestProduct = subscriptionData.products.find(
(p) => p.productId === getWorkspacePlanProductId({ workspacePlan: 'guest' })
)
if (guestProduct) {
mutateSubscriptionDataWithNewValidSeatNumbers({
seatCount: 0,
getWorkspacePlanProductId,
subscriptionData,
workspacePlan: 'guest'
})
subscriptionData.products.push({
quantity: guestCount,
productId: getWorkspacePlanProductId({ workspacePlan: 'guest' }),
priceId: getWorkspacePlanPrice({
workspacePlan: 'guest',
billingInterval
}),
subscriptionItemId: undefined
})
}
}
// set current plan seat count to 0
mutateSubscriptionDataWithNewValidSeatNumbers({
@@ -420,11 +477,11 @@ export const upgradeWorkspaceSubscriptionFactory =
// set target plan seat count to current seat count
subscriptionData.products.push({
quantity: seatCount,
quantity: memberCount + adminCount,
productId: getWorkspacePlanProductId({ workspacePlan: targetPlan }),
priceId: getWorkspacePlanPrice({
workspacePlan: targetPlan,
billingInterval: workspaceSubscription.billingInterval
billingInterval
}),
subscriptionItemId: undefined
})
@@ -438,4 +495,5 @@ export const upgradeWorkspaceSubscriptionFactory =
createdAt: new Date()
}
})
await updateWorkspaceSubscription({ workspaceSubscription })
}
@@ -923,12 +923,19 @@ describe('subscriptions @gatekeeper', () => {
},
upsertWorkspacePlan: () => {
expect.fail()
},
updateWorkspaceSubscription: () => {
expect.fail()
},
countWorkspaceRole: () => {
expect.fail()
}
})
const err = await expectToThrow(async () => {
await upgradeWorkspaceSubscription({
workspaceId,
targetPlan: 'business'
targetPlan: 'business',
billingInterval: 'monthly'
})
})
@@ -958,12 +965,19 @@ describe('subscriptions @gatekeeper', () => {
},
upsertWorkspacePlan: () => {
expect.fail()
},
updateWorkspaceSubscription: () => {
expect.fail()
},
countWorkspaceRole: () => {
expect.fail()
}
})
const err = await expectToThrow(async () => {
await upgradeWorkspaceSubscription({
workspaceId,
targetPlan: 'business'
targetPlan: 'business',
billingInterval: 'monthly'
})
})
@@ -996,12 +1010,19 @@ describe('subscriptions @gatekeeper', () => {
},
upsertWorkspacePlan: () => {
expect.fail()
},
updateWorkspaceSubscription: () => {
expect.fail()
},
countWorkspaceRole: () => {
expect.fail()
}
})
const err = await expectToThrow(async () => {
await upgradeWorkspaceSubscription({
workspaceId,
targetPlan: 'business'
targetPlan: 'business',
billingInterval: 'monthly'
})
})
@@ -1035,12 +1056,19 @@ describe('subscriptions @gatekeeper', () => {
},
upsertWorkspacePlan: () => {
expect.fail()
},
updateWorkspaceSubscription: () => {
expect.fail()
},
countWorkspaceRole: () => {
expect.fail()
}
})
const err = await expectToThrow(async () => {
await upgradeWorkspaceSubscription({
workspaceId,
targetPlan: 'business'
targetPlan: 'business',
billingInterval: 'monthly'
})
})
@@ -1071,18 +1099,25 @@ describe('subscriptions @gatekeeper', () => {
},
upsertWorkspacePlan: () => {
expect.fail()
},
updateWorkspaceSubscription: () => {
expect.fail()
},
countWorkspaceRole: () => {
expect.fail()
}
})
const err = await expectToThrow(async () => {
await upgradeWorkspaceSubscription({
workspaceId,
targetPlan: 'business'
targetPlan: 'business',
billingInterval: 'monthly'
})
})
expect(err.message).to.equal(new WorkspaceSubscriptionNotFoundError().message)
})
it('throws WorkspacePlanDowngradeError', async () => {
it('throws WorkspacePlanDowngradeError for downgrading the plan', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const workspaceSubscription = createTestWorkspaceSubscription()
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
@@ -1106,12 +1141,108 @@ describe('subscriptions @gatekeeper', () => {
},
upsertWorkspacePlan: () => {
expect.fail()
},
updateWorkspaceSubscription: () => {
expect.fail()
},
countWorkspaceRole: () => {
expect.fail()
}
})
const err = await expectToThrow(async () => {
await upgradeWorkspaceSubscription({
workspaceId,
targetPlan: 'business'
targetPlan: 'business',
billingInterval: 'monthly'
})
})
expect(err.message).to.equal(new WorkspacePlanDowngradeError().message)
})
it('throws WorkspacePlanDowngradeError for downgrading the billing interval', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const workspaceSubscription = createTestWorkspaceSubscription({
billingInterval: 'yearly'
})
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
getWorkspacePlan: async () => ({
workspaceId,
createdAt: new Date(),
name: 'business',
status: 'valid'
}),
getWorkspacePlanProductId: () => {
expect.fail()
},
getWorkspacePlanPrice: () => {
expect.fail()
},
getWorkspaceSubscription: async () => {
return workspaceSubscription
},
reconcileSubscriptionData: () => {
expect.fail()
},
upsertWorkspacePlan: () => {
expect.fail()
},
updateWorkspaceSubscription: () => {
expect.fail()
},
countWorkspaceRole: () => {
expect.fail()
}
})
const err = await expectToThrow(async () => {
await upgradeWorkspaceSubscription({
workspaceId,
targetPlan: 'business',
billingInterval: 'monthly'
})
})
expect(err.message).to.equal(new WorkspacePlanDowngradeError().message)
})
it('throws WorkspacePlanDowngradeError for noop requests', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const workspaceSubscription = createTestWorkspaceSubscription({
billingInterval: 'monthly'
})
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
getWorkspacePlan: async () => ({
workspaceId,
createdAt: new Date(),
name: 'business',
status: 'valid'
}),
getWorkspacePlanProductId: () => {
expect.fail()
},
getWorkspacePlanPrice: () => {
expect.fail()
},
getWorkspaceSubscription: async () => {
return workspaceSubscription
},
reconcileSubscriptionData: () => {
expect.fail()
},
upsertWorkspacePlan: () => {
expect.fail()
},
updateWorkspaceSubscription: () => {
expect.fail()
},
countWorkspaceRole: () => {
expect.fail()
}
})
const err = await expectToThrow(async () => {
await upgradeWorkspaceSubscription({
workspaceId,
targetPlan: 'business',
billingInterval: 'monthly'
})
})
@@ -1150,12 +1281,19 @@ describe('subscriptions @gatekeeper', () => {
},
upsertWorkspacePlan: () => {
expect.fail()
},
updateWorkspaceSubscription: () => {
expect.fail()
},
countWorkspaceRole: () => {
expect.fail()
}
})
const err = await expectToThrow(async () => {
await upgradeWorkspaceSubscription({
workspaceId,
targetPlan: 'business'
targetPlan: 'business',
billingInterval: 'monthly'
})
})
@@ -1184,11 +1322,13 @@ describe('subscriptions @gatekeeper', () => {
]
}
const workspaceSubscription = createTestWorkspaceSubscription({
subscriptionData
subscriptionData,
billingInterval: 'monthly'
})
let reconciledSubscriptionData: SubscriptionDataInput | undefined = undefined
let updatedWorkspacePlan: WorkspacePlan | undefined = undefined
let updatedWorkspaceSubscription: WorkspaceSubscription | undefined = undefined
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
getWorkspacePlan: async () => ({
workspaceId,
@@ -1219,27 +1359,36 @@ describe('subscriptions @gatekeeper', () => {
},
upsertWorkspacePlan: async ({ workspacePlan }) => {
updatedWorkspacePlan = workspacePlan
},
updateWorkspaceSubscription: async ({ workspaceSubscription }) => {
updatedWorkspaceSubscription = workspaceSubscription
},
countWorkspaceRole: async () => {
return 4
}
})
await upgradeWorkspaceSubscription({
workspaceId,
targetPlan: 'business'
targetPlan: 'business',
billingInterval: 'yearly'
})
expect(updatedWorkspacePlan!.name).to.equal('business')
expect(reconciledSubscriptionData!.products.length).to.equal(2)
expect(updatedWorkspaceSubscription!.billingInterval === 'yearly')
expect(
reconciledSubscriptionData!.products.find(
(p) => p.productId === 'guestProduct'
)!.quantity
).to.equal(10)
).to.equal(4)
const newProduct = reconciledSubscriptionData!.products.find(
(p) => p.productId === 'businessProduct'
)
expect(newProduct!.quantity).to.equal(20)
expect(newProduct!.quantity).to.equal(8)
expect(newProduct!.priceId).to.equal('newPlanPrice')
})
})
@@ -3541,8 +3541,9 @@ export type UpdateVersionInput = {
};
export type UpgradePlanInput = {
targetPlan: PaidWorkspacePlans;
billingInterval: BillingInterval;
workspaceId: Scalars['ID']['input'];
workspacePlan: PaidWorkspacePlans;
};
/**