Fix: Various billing fixes (#3569)
This commit is contained in:
@@ -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 ↗
|
||||
</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>
|
||||
|
||||
+5
-11
@@ -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)
|
||||
|
||||
+72
-10
@@ -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
|
||||
<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)
|
||||
|
||||
+18
-6
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user