Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Add cancel billing sessions, billing alerts, assume no plan is trial plan #3467

Merged
merged 8 commits into from
Nov 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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' }}
Mikehrn marked this conversation as resolved.
Show resolved Hide resolved
</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