Skip to content

Commit

Permalink
Feat: Add cancel billing sessions, billing alerts, assume no plan is …
Browse files Browse the repository at this point in the history
…trial plan (#3467)
  • Loading branch information
Mikehrn authored Nov 16, 2024
1 parent adeb529 commit 423350e
Show file tree
Hide file tree
Showing 11 changed files with 310 additions and 126 deletions.
102 changes: 102 additions & 0 deletions packages/frontend-2/components/billing/Alert.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<template>
<CommonCard class="bg-foundation py-3 px-4">
<div class="flex gap-x-2">
<ExclamationCircleIcon v-if="showIcon" class="h-4 w-4 text-danger mt-1" />
<div class="flex-1 flex gap-x-4 items-center">
<div class="flex-1">
<h5 class="text-body-xs font-medium text-foreground">{{ title }}</h5>
<p class="text-body-xs text-foreground-2">{{ description }}</p>
</div>
<FormButton
v-if="isPaymentFailed"
:icon-right="ArrowTopRightOnSquareIcon"
@click="billingPortalRedirect(workspace.id)"
>
Update payment information
</FormButton>
</div>
</div>
</CommonCard>
</template>

<script setup lang="ts">
import {
ExclamationCircleIcon,
ArrowTopRightOnSquareIcon
} from '@heroicons/vue/24/outline'
import { graphql } from '~/lib/common/generated/gql'
import {
type BillingAlert_WorkspaceFragment,
WorkspacePlanStatuses,
WorkspacePlans
} from '~/lib/common/generated/gql/graphql'
import { useBillingActions } from '~/lib/billing/composables/actions'
graphql(`
fragment BillingAlert_Workspace on Workspace {
id
plan {
name
status
}
subscription {
billingInterval
currentBillingCycleEnd
}
}
`)
const props = defineProps<{
workspace: BillingAlert_WorkspaceFragment
}>()
const { billingPortalRedirect } = useBillingActions()
const planStatus = computed(() => props.workspace.plan?.status)
// If there is no plan status, we assume it's a trial
const isTrial = computed(
() => !planStatus.value || planStatus.value === WorkspacePlanStatuses.Trial
)
const isPaymentFailed = computed(
() => planStatus.value === WorkspacePlanStatuses.PaymentFailed
)
const title = computed(() => {
if (isTrial.value) {
return `You are currently on a free ${
props.workspace.plan?.name ?? WorkspacePlans.Team
} plan trial`
}
switch (planStatus.value) {
case WorkspacePlanStatuses.CancelationScheduled:
return `Your ${props.workspace.plan?.name} plan subscription is scheduled for cancelation`
case WorkspacePlanStatuses.Canceled:
return `Your ${props.workspace.plan?.name} plan subscription has been canceled`
case WorkspacePlanStatuses.Expired:
return `Your free ${props.workspace.plan?.name} plan trial has ended`
case WorkspacePlanStatuses.PaymentFailed:
return "Your last payment didn't go through"
default:
return ''
}
})
const description = computed(() => {
if (isTrial.value) {
return 'Upgrade to a paid plan to start your subscription.'
}
switch (planStatus.value) {
case WorkspacePlanStatuses.CancelationScheduled:
return 'Your workspace subscription is scheduled for cancelation. After the cancelation, your workspace will be in read-only mode.'
case WorkspacePlanStatuses.Canceled:
return 'Your workspace has been canceled and is in read-only mode. Upgrade your plan to continue.'
case WorkspacePlanStatuses.Expired:
return "The workspace is in a read-only locked state until there's an active subscription. Upgrade your plan to continue."
case WorkspacePlanStatuses.PaymentFailed:
return "Update your payment information now to ensure your workspace doesn't go into maintenance mode."
default:
return ''
}
})
const showIcon = computed(() => {
return !!planStatus.value && planStatus.value !== WorkspacePlanStatuses.Trial
})
</script>
59 changes: 0 additions & 59 deletions packages/frontend-2/components/billing/Summary.vue

This file was deleted.

132 changes: 77 additions & 55 deletions packages/frontend-2/components/settings/workspaces/Billing.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,38 @@
<div class="md:max-w-4xl md:mx-auto pb-6 md:pb-0">
<SettingsSectionHeader title="Billing" text="Your workspace billing details" />
<template v-if="isBillingIntegrationEnabled">
<BillingAlert
v-if="workspaceResult && !isValidPlan"
:workspace="workspaceResult.workspace"
class="mb-4"
/>
<div class="flex flex-col gap-y-4 md:gap-y-6">
<SettingsSectionHeader title="Billing summary" subheading class="pt-4" />
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<CommonCard class="gap-y-1 bg-foundation">
<p class="text-body-xs text-foreground-2 font-medium">
<p class="text-body-xs text-foreground-2">
{{ isTrialPeriod ? 'Trial plan' : 'Current plan' }}
</p>
<h4 class="text-heading-lg text-foreground capitalize">
{{ currentPlan?.name }} plan
{{ currentPlan?.name ?? WorkspacePlans.Team }} plan
</h4>
<p
v-if="currentPlan?.name && subscription?.billingInterval"
class="text-body-xs text-foreground-2"
>
<p class="text-body-xs text-foreground-2">
£{{ seatPrice }} per seat/month, billed
{{ subscription?.billingInterval }}
{{
subscription?.billingInterval === BillingInterval.Yearly
? 'yearly'
: 'monthly'
}}
</p>
</CommonCard>
<CommonCard class="gap-y-1 bg-foundation">
<p class="text-body-xs text-foreground-2">
{{
isTrialPeriod
? 'Expected bill'
: subscription?.billingInterval === BillingInterval.Monthly
? 'Monthly bill'
: 'Yearly bill'
: subscription?.billingInterval === BillingInterval.Yearly
? 'Yearly bill'
: 'Monthly bill'
}}
</p>
<h4 class="text-heading-lg text-foreground capitalize">Coming soon</h4>
Expand All @@ -38,14 +44,16 @@
{{ isTrialPeriod ? 'First payment due' : 'Next payment due' }}
</p>
<h4 class="text-heading-lg text-foreground capitalize">
{{
isPaidPlan
? dayjs(subscription?.currentBillingCycleEnd).format('MMMM D, YYYY')
: 'Never'
}}
{{ nextPaymentDue }}
</h4>
<p v-if="isPaidPlan" class="text-body-xs text-foreground-2">
<span class="capitalize">{{ subscription?.billingInterval }}</span>
<span class="capitalize">
{{
subscription?.billingInterval === BillingInterval.Yearly
? 'Yearly'
: 'Monthly'
}}
</span>
billing period
</p>
</CommonCard>
Expand All @@ -60,7 +68,7 @@
<FormButton
color="outline"
:icon-right="ArrowTopRightOnSquareIcon"
@click="openCustomerPortal"
@click="billingPortalRedirect(workspaceId)"
>
Open billing portal
</FormButton>
Expand Down Expand Up @@ -110,11 +118,10 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import { graphql } from '~/lib/common/generated/gql'
import { useQuery, useApolloClient } from '@vue/apollo-composable'
import { useQuery } from '@vue/apollo-composable'
import {
settingsWorkspaceBillingQuery,
settingsWorkspacePricingPlansQuery,
settingsWorkspaceBillingCustomerPortalQuery
settingsWorkspacePricingPlansQuery
} from '~/lib/settings/graphql/queries'
import { useIsBillingIntegrationEnabled } from '~/composables/globals'
import {
Expand All @@ -124,9 +131,13 @@ import {
} from '~/lib/common/generated/gql/graphql'
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/outline'
import { isWorkspacePricingPlans } from '~/lib/settings/helpers/types'
import { useBillingActions } from '~/lib/billing/composables/actions'
import type { SeatPrices } from '~/lib/billing/helpers/types'
import { seatPricesConfig } from '~/lib/billing/helpers/constants'
graphql(`
fragment SettingsWorkspacesBilling_Workspace on Workspace {
...BillingAlert_Workspace
id
plan {
name
Expand All @@ -139,33 +150,33 @@ graphql(`
}
`)
type SeatPrices = {
[key in WorkspacePlans]: {
[BillingInterval.Monthly]: number
[BillingInterval.Yearly]: number
}
}
const props = defineProps<{
workspaceId: string
}>()
const isBillingIntegrationEnabled = useIsBillingIntegrationEnabled()
const isYearlyPlan = ref(false)
// TODO: get these from the backend when available
const seatPrices = ref<SeatPrices>({
[WorkspacePlans.Team]: { monthly: 12, yearly: 10 },
[WorkspacePlans.Pro]: { monthly: 40, yearly: 36 },
[WorkspacePlans.Business]: { monthly: 79, yearly: 63 },
[WorkspacePlans.Academia]: { monthly: 0, yearly: 0 },
[WorkspacePlans.Unlimited]: { monthly: 0, yearly: 0 }
})
const seatPrices = ref<SeatPrices>(seatPricesConfig)
const { client: apollo } = useApolloClient()
const { result: workspaceResult } = useQuery(settingsWorkspaceBillingQuery, () => ({
workspaceId: props.workspaceId
}))
const { result: pricingPlansResult } = useQuery(settingsWorkspacePricingPlansQuery)
const route = useRoute()
const { result: workspaceResult } = useQuery(
settingsWorkspaceBillingQuery,
() => ({
workspaceId: props.workspaceId
}),
() => ({
enabled: isBillingIntegrationEnabled
})
)
const { result: pricingPlansResult } = useQuery(
settingsWorkspacePricingPlansQuery,
null,
() => ({
enabled: isBillingIntegrationEnabled
})
)
const { billingPortalRedirect, upgradePlanRedirect, cancelCheckoutSession } =
useBillingActions()
const currentPlan = computed(() => workspaceResult.value?.workspace.plan)
const subscription = computed(() => workspaceResult.value?.workspace.subscription)
Expand All @@ -175,39 +186,50 @@ const isPaidPlan = computed(
currentPlan.value?.name !== WorkspacePlans.Unlimited
)
const isTrialPeriod = computed(
() => currentPlan.value?.status === WorkspacePlanStatuses.Trial
() =>
currentPlan.value?.status === WorkspacePlanStatuses.Trial ||
!currentPlan.value?.status
)
const isActivePlan = computed(
() =>
currentPlan.value &&
currentPlan.value?.status !== WorkspacePlanStatuses.Trial &&
currentPlan.value?.status !== WorkspacePlanStatuses.Canceled
)
const isValidPlan = computed(
() => currentPlan.value?.status === WorkspacePlanStatuses.Valid
)
const seatPrice = computed(() =>
currentPlan.value && subscription.value
? seatPrices.value[currentPlan.value?.name][subscription.value?.billingInterval]
: 0
: seatPrices.value[WorkspacePlans.Team][BillingInterval.Monthly]
)
const pricingPlans = computed(() =>
isWorkspacePricingPlans(pricingPlansResult.value)
? pricingPlansResult.value?.workspacePricingPlans.workspacePlanInformation
: undefined
)
const nextPaymentDue = computed(() =>
currentPlan.value
? isPaidPlan.value
? dayjs(subscription.value?.currentBillingCycleEnd).format('MMMM D, YYYY')
: 'Never'
: dayjs().add(30, 'days').format('MMMM D, YYYY')
)
const onUpgradePlanClick = (plan: WorkspacePlans) => {
const cycle = isYearlyPlan.value ? BillingInterval.Yearly : BillingInterval.Monthly
window.location.href = `/api/v1/billing/workspaces/${props.workspaceId}/checkout-session/${plan}/${cycle}`
upgradePlanRedirect({
plan,
cycle: isYearlyPlan.value ? BillingInterval.Yearly : BillingInterval.Monthly,
workspaceId: props.workspaceId
})
}
const openCustomerPortal = async () => {
// We need to fetch this on click because the link expires very quickly
const result = await apollo.query({
query: settingsWorkspaceBillingCustomerPortalQuery,
variables: { workspaceId: props.workspaceId },
fetchPolicy: 'no-cache'
})
onMounted(() => {
const paymentStatusQuery = route.query?.payment_status
const sessionIdQuery = route.query?.session_id
if (result.data?.workspace.customerPortalUrl) {
window.location.href = result.data.workspace.customerPortalUrl
if (sessionIdQuery && String(paymentStatusQuery) === WorkspacePlanStatuses.Canceled) {
cancelCheckoutSession(String(sessionIdQuery), props.workspaceId)
}
}
})
</script>
Loading

0 comments on commit 423350e

Please sign in to comment.