🛂 Add new yearly plans and graduated pricing

BREAKING CHANGE: Stripe environment variables have changed. New ones are required. Check out the new Stripe configuration in the
docs.

Closes #457
This commit is contained in:
Baptiste Arnaud
2023-04-13 11:39:10 +02:00
parent 39d0dba18c
commit 2cbf8348c3
33 changed files with 1257 additions and 1399 deletions

View File

@@ -1,70 +0,0 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { Plan, WorkspaceRole } from '@typebot.io/prisma'
import Stripe from 'stripe'
import { z } from 'zod'
export const cancelSubscription = authenticatedProcedure
.meta({
openapi: {
method: 'DELETE',
path: '/billing/subscription',
protect: true,
summary: 'Cancel current subscription',
tags: ['Billing'],
},
})
.input(
z.object({
workspaceId: z.string(),
})
)
.output(
z.object({
message: z.literal('success'),
})
)
.mutation(async ({ input: { workspaceId }, ctx: { user } }) => {
if (
!process.env.STRIPE_SECRET_KEY ||
!process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID ||
!process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Stripe environment variables are missing',
})
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
members: { some: { userId: user.id, role: WorkspaceRole.ADMIN } },
},
})
if (!workspace?.stripeId)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Workspace not found',
})
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2022-11-15',
})
const currentSubscriptionId = (
await stripe.subscriptions.list({
customer: workspace.stripeId,
})
).data.shift()?.id
if (currentSubscriptionId)
await stripe.subscriptions.del(currentSubscriptionId)
await prisma.workspace.update({
where: { id: workspace.id },
data: {
plan: Plan.FREE,
additionalChatsIndex: 0,
additionalStorageIndex: 0,
},
})
return { message: 'success' }
})

View File

@@ -33,6 +33,7 @@ export const createCheckoutSession = authenticatedProcedure
value: z.string(),
})
.optional(),
isYearly: z.boolean(),
})
)
.output(
@@ -52,14 +53,11 @@ export const createCheckoutSession = authenticatedProcedure
returnUrl,
additionalChats,
additionalStorage,
isYearly,
},
ctx: { user },
}) => {
if (
!process.env.STRIPE_SECRET_KEY ||
!process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID ||
!process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
)
if (!process.env.STRIPE_SECRET_KEY)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Stripe environment variables are missing',
@@ -120,7 +118,8 @@ export const createCheckoutSession = authenticatedProcedure
line_items: parseSubscriptionItems(
plan,
additionalChats,
additionalStorage
additionalStorage,
isYearly
),
})

View File

@@ -5,6 +5,7 @@ import { WorkspaceRole } from '@typebot.io/prisma'
import Stripe from 'stripe'
import { z } from 'zod'
import { subscriptionSchema } from '@typebot.io/schemas/features/billing/subscription'
import { priceIds } from '@typebot.io/lib/pricing'
export const getSubscription = authenticatedProcedure
.meta({
@@ -23,15 +24,11 @@ export const getSubscription = authenticatedProcedure
)
.output(
z.object({
subscription: subscriptionSchema,
subscription: subscriptionSchema.or(z.null()),
})
)
.query(async ({ input: { workspaceId }, ctx: { user } }) => {
if (
!process.env.STRIPE_SECRET_KEY ||
!process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID ||
!process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
)
if (!process.env.STRIPE_SECRET_KEY)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Stripe environment variables are missing',
@@ -43,10 +40,9 @@ export const getSubscription = authenticatedProcedure
},
})
if (!workspace?.stripeId)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Workspace not found',
})
return {
subscription: null,
}
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2022-11-15',
})
@@ -58,24 +54,34 @@ export const getSubscription = authenticatedProcedure
const subscription = subscriptions?.data.shift()
if (!subscription)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Subscription not found',
})
return {
subscription: null,
}
return {
subscription: {
additionalChatsIndex:
subscription?.items.data.find(
(item) =>
item.price.id === process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID
)?.quantity ?? 0,
additionalStorageIndex:
subscription.items.data.find(
(item) =>
item.price.id === process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
)?.quantity ?? 0,
isYearly: subscription.items.data.some((item) => {
return (
priceIds.STARTER.chats.yearly === item.price.id ||
priceIds.STARTER.storage.yearly === item.price.id ||
priceIds.PRO.chats.yearly === item.price.id ||
priceIds.PRO.storage.yearly === item.price.id
)
}),
currency: subscription.currency as 'usd' | 'eur',
cancelDate: subscription.cancel_at
? new Date(subscription.cancel_at * 1000)
: undefined,
},
}
})
export const chatPriceIds = [priceIds.STARTER.chats.monthly]
.concat(priceIds.STARTER.chats.yearly)
.concat(priceIds.PRO.chats.monthly)
.concat(priceIds.PRO.chats.yearly)
export const storagePriceIds = [priceIds.STARTER.storage.monthly]
.concat(priceIds.STARTER.storage.yearly)
.concat(priceIds.PRO.storage.monthly)
.concat(priceIds.PRO.storage.yearly)

View File

@@ -1,5 +1,4 @@
import { router } from '@/helpers/server/trpc'
import { cancelSubscription } from './cancelSubscription'
import { createCheckoutSession } from './createCheckoutSession'
import { getBillingPortalUrl } from './getBillingPortalUrl'
import { getSubscription } from './getSubscription'
@@ -10,7 +9,6 @@ import { updateSubscription } from './updateSubscription'
export const billingRouter = router({
getBillingPortalUrl,
listInvoices,
cancelSubscription,
createCheckoutSession,
updateSubscription,
getSubscription,

View File

@@ -7,6 +7,12 @@ import { workspaceSchema } from '@typebot.io/schemas'
import Stripe from 'stripe'
import { isDefined } from '@typebot.io/lib'
import { z } from 'zod'
import {
getChatsLimit,
getStorageLimit,
priceIds,
} from '@typebot.io/lib/pricing'
import { chatPriceIds, storagePriceIds } from './getSubscription'
export const updateSubscription = authenticatedProcedure
.meta({
@@ -25,6 +31,7 @@ export const updateSubscription = authenticatedProcedure
additionalChats: z.number(),
additionalStorage: z.number(),
currency: z.enum(['usd', 'eur']),
isYearly: z.boolean(),
})
)
.output(
@@ -40,14 +47,11 @@ export const updateSubscription = authenticatedProcedure
additionalChats,
additionalStorage,
currency,
isYearly,
},
ctx: { user },
}) => {
if (
!process.env.STRIPE_SECRET_KEY ||
!process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID ||
!process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
)
if (!process.env.STRIPE_SECRET_KEY)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Stripe environment variables are missing',
@@ -70,42 +74,48 @@ export const updateSubscription = authenticatedProcedure
customer: workspace.stripeId,
})
const subscription = data[0] as Stripe.Subscription | undefined
const currentStarterPlanItemId = subscription?.items.data.find(
(item) => item.price.id === process.env.STRIPE_STARTER_PRICE_ID
)?.id
const currentProPlanItemId = subscription?.items.data.find(
(item) => item.price.id === process.env.STRIPE_PRO_PRICE_ID
const currentPlanItemId = subscription?.items.data.find((item) =>
[
process.env.STRIPE_STARTER_PRODUCT_ID,
process.env.STRIPE_PRO_PRODUCT_ID,
].includes(item.price.product.toString())
)?.id
const currentAdditionalChatsItemId = subscription?.items.data.find(
(item) => item.price.id === process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID
(item) => chatPriceIds.includes(item.price.id)
)?.id
const currentAdditionalStorageItemId = subscription?.items.data.find(
(item) =>
item.price.id === process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
(item) => storagePriceIds.includes(item.price.id)
)?.id
const frequency = isYearly ? 'yearly' : 'monthly'
const items = [
{
id: currentStarterPlanItemId ?? currentProPlanItemId,
price:
plan === Plan.STARTER
? process.env.STRIPE_STARTER_PRICE_ID
: process.env.STRIPE_PRO_PRICE_ID,
id: currentPlanItemId,
price: priceIds[plan].base[frequency],
quantity: 1,
},
additionalChats === 0 && !currentAdditionalChatsItemId
? undefined
: {
id: currentAdditionalChatsItemId,
price: process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID,
quantity: additionalChats,
price: priceIds[plan].chats[frequency],
quantity: getChatsLimit({
plan,
additionalChatsIndex: additionalChats,
customChatsLimit: null,
}),
deleted: subscription ? additionalChats === 0 : undefined,
},
additionalStorage === 0 && !currentAdditionalStorageItemId
? undefined
: {
id: currentAdditionalStorageItemId,
price: process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID,
quantity: additionalStorage,
price: priceIds[plan].storage[frequency],
quantity: getStorageLimit({
plan,
additionalStorageIndex: additionalStorage,
customStorageLimit: null,
}),
deleted: subscription ? additionalStorage === 0 : undefined,
},
].filter(isDefined)
@@ -126,6 +136,9 @@ export const updateSubscription = authenticatedProcedure
items,
currency,
default_payment_method: paymentMethods[0].id,
automatic_tax: {
enabled: true,
},
})
}

View File

@@ -1,5 +1,6 @@
import {
addSubscriptionToWorkspace,
cancelSubscription,
createClaimableCustomPlan,
} from '@/test/utils/databaseActions'
import test, { expect } from '@playwright/test'
@@ -75,7 +76,7 @@ test('should display valid usage', async ({ page }) => {
await page.click('text="Free workspace"')
await page.click('text=Settings & Members')
await page.click('text=Billing & Usage')
await expect(page.locator('text="/ 300"')).toBeVisible()
await expect(page.locator('text="/ 200"')).toBeVisible()
await expect(page.locator('text="Storage"')).toBeHidden()
await page.getByText('Members', { exact: true }).click()
await expect(
@@ -132,6 +133,7 @@ test('plan changes should work', async ({ page }) => {
await page.click('button >> text="3,500"')
await page.click('button >> text="2"')
await page.click('button >> text="4"')
await page.locator('label span').first().click()
await expect(page.locator('text="$73"')).toBeVisible()
await page.click('button >> text=Upgrade >> nth=0')
await page.getByLabel('Company name').fill('Company LLC')
@@ -141,11 +143,11 @@ test('plan changes should work', async ({ page }) => {
await expect(page.locator('text=$73.00 >> nth=0')).toBeVisible()
await expect(page.locator('text=$30.00 >> nth=0')).toBeVisible()
await expect(page.locator('text=$4.00 >> nth=0')).toBeVisible()
await addSubscriptionToWorkspace(
const stripeId = await addSubscriptionToWorkspace(
planChangeWorkspaceId,
[
{
price: process.env.STRIPE_STARTER_PRICE_ID,
price: process.env.STRIPE_STARTER_MONTHLY_PRICE_ID,
quantity: 1,
},
],
@@ -158,8 +160,8 @@ test('plan changes should work', async ({ page }) => {
await page.click('text=Billing & Usage')
await expect(page.locator('text="/ 2,000"')).toBeVisible()
await expect(page.locator('text="/ 2 GB"')).toBeVisible()
await expect(page.locator('button >> text="2,000"')).toBeVisible()
await expect(page.locator('button >> text="2"')).toBeVisible()
await expect(page.getByText('/ 2,000')).toBeVisible()
await expect(page.getByText('/ 2 GB')).toBeVisible()
await page.click('button >> text="2,000"')
await page.click('button >> text="3,500"')
await page.click('button >> text="2"')
@@ -176,15 +178,15 @@ test('plan changes should work', async ({ page }) => {
await expect(page.locator('text="$73"')).toBeVisible()
await expect(page.locator('text="/ 3,500"')).toBeVisible()
await expect(page.locator('text="/ 4 GB"')).toBeVisible()
await expect(page.locator('button >> text="3,500"')).toBeVisible()
await expect(page.locator('button >> text="4"')).toBeVisible()
await expect(page.getByRole('button', { name: '3,500' })).toBeVisible()
await expect(page.getByRole('button', { name: '4' })).toBeVisible()
// Upgrade to PRO
await page.click('button >> text="10,000"')
await page.click('button >> text="14,000"')
await page.click('button >> text="25,000"')
await page.click('button >> text="10"')
await page.click('button >> text="12"')
await expect(page.locator('text="$133"')).toBeVisible()
await page.click('button >> text="15"')
await expect(page.locator('text="$247"')).toBeVisible()
await page.click('button >> text=Upgrade')
await expect(
page.locator('text="Workspace PRO plan successfully updated 🎉" >> nth=0')
@@ -195,25 +197,20 @@ test('plan changes should work', async ({ page }) => {
page.waitForNavigation(),
page.click('text="Billing portal"'),
])
await expect(page.getByText('$247.00 per month')).toBeVisible()
await expect(page.getByText('(×25000)')).toBeVisible()
await expect(page.getByText('(×15)')).toBeVisible()
await expect(page.locator('text="Add payment method"')).toBeVisible()
await cancelSubscription(stripeId)
// Cancel subscription
await page.goto('/typebots')
await page.click('text=Settings & Members')
await page.click('text=Billing & Usage')
await expect(page.locator('[data-testid="current-subscription"]')).toHaveText(
'Current workspace subscription: ProCancel my subscription'
)
await page.click('button >> text="Cancel my subscription"')
await expect(page.locator('[data-testid="current-subscription"]')).toHaveText(
'Current workspace subscription: Free'
)
// Upgrade again to PRO
await page.getByRole('button', { name: 'Upgrade' }).nth(1).click()
await expect(
page.locator('text="Workspace PRO plan successfully updated 🎉" >> nth=0')
).toBeVisible({ timeout: 20 * 1000 })
page.getByTestId('current-subscription').getByTestId('pro-plan-tag')
).toBeVisible()
await expect(page.getByText('Will be cancelled on')).toBeVisible()
})
test('should display invoices', async ({ page }) => {
@@ -228,7 +225,7 @@ test('should display invoices', async ({ page }) => {
await page.click('text=Settings & Members')
await page.click('text=Billing & Usage')
await expect(page.locator('text="Invoices"')).toBeVisible()
await expect(page.locator('tr')).toHaveCount(3)
await expect(page.locator('tr')).toHaveCount(2)
await expect(page.locator('text="$39.00"')).toBeVisible()
})

View File

@@ -1,17 +1,13 @@
import { HStack, Stack, Text } from '@chakra-ui/react'
import { Stack } from '@chakra-ui/react'
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
import { Plan } from '@typebot.io/prisma'
import React from 'react'
import { InvoicesList } from './InvoicesList'
import { StripeClimateLogo } from './StripeClimateLogo'
import { TextLink } from '@/components/TextLink'
import { ChangePlanForm } from './ChangePlanForm'
import { UsageProgressBars } from './UsageProgressBars'
import { CurrentSubscriptionSummary } from './CurrentSubscriptionSummary'
import { useScopedI18n } from '@/locales'
export const BillingSettingsLayout = () => {
const scopedT = useScopedI18n('billing')
const { workspace, refreshWorkspace } = useWorkspace()
if (!workspace) return null
@@ -19,19 +15,7 @@ export const BillingSettingsLayout = () => {
<Stack spacing="10" w="full">
<UsageProgressBars workspace={workspace} />
<Stack spacing="4">
<CurrentSubscriptionSummary
workspace={workspace}
onCancelSuccess={refreshWorkspace}
/>
<HStack maxW="500px">
<StripeClimateLogo />
<Text fontSize="xs" color="gray.500">
{scopedT('contribution.preLink')}{' '}
<TextLink href="https://climate.stripe.com/5VCRAq" isExternal>
{scopedT('contribution.link')}
</TextLink>
</Text>
</HStack>
<CurrentSubscriptionSummary workspace={workspace} />
{workspace.plan !== Plan.CUSTOM &&
workspace.plan !== Plan.LIFETIME &&
workspace.plan !== Plan.UNLIMITED &&

View File

@@ -1,4 +1,4 @@
import { Stack, HStack, Text } from '@chakra-ui/react'
import { Stack, HStack, Text, Switch, Tag } from '@chakra-ui/react'
import { Plan } from '@typebot.io/prisma'
import { TextLink } from '@/components/TextLink'
import { useToast } from '@/hooks/useToast'
@@ -12,22 +12,33 @@ import { useUser } from '@/features/account/hooks/useUser'
import { StarterPlanPricingCard } from './StarterPlanPricingCard'
import { ProPlanPricingCard } from './ProPlanPricingCard'
import { useScopedI18n } from '@/locales'
import { StripeClimateLogo } from './StripeClimateLogo'
type Props = {
workspace: Pick<Workspace, 'id' | 'stripeId' | 'plan'>
workspace: Workspace
onUpgradeSuccess: () => void
}
export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
const scopedT = useScopedI18n('billing')
const { user } = useUser()
const { showToast } = useToast()
const [preCheckoutPlan, setPreCheckoutPlan] =
useState<PreCheckoutModalProps['selectedSubscription']>()
const [isYearly, setIsYearly] = useState(true)
const { data } = trpc.billing.getSubscription.useQuery({
workspaceId: workspace.id,
})
const { data } = trpc.billing.getSubscription.useQuery(
{
workspaceId: workspace.id,
},
{
onSuccess: ({ subscription }) => {
if (isYearly === false) return
setIsYearly(subscription?.isYearly ?? true)
},
}
)
const { mutate: updateSubscription, isLoading: isUpdatingSubscription } =
trpc.billing.updateSubscription.useMutation({
@@ -67,8 +78,9 @@ export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
additionalChats: selectedChatsLimitIndex,
additionalStorage: selectedStorageLimitIndex,
currency:
data?.subscription.currency ??
data?.subscription?.currency ??
(guessIfUserIsEuropean() ? 'eur' : 'usd'),
isYearly,
} as const
if (workspace.stripeId) {
updateSubscription(newSubscription)
@@ -77,8 +89,19 @@ export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
}
}
if (data?.subscription?.cancelDate) return null
return (
<Stack spacing={6}>
<HStack maxW="500px">
<StripeClimateLogo />
<Text fontSize="xs" color="gray.500">
{scopedT('contribution.preLink')}{' '}
<TextLink href="https://climate.stripe.com/5VCRAq" isExternal>
{scopedT('contribution.link')}
</TextLink>
</Text>
</HStack>
{!workspace.stripeId && (
<ParentModalProvider>
<PreCheckoutModal
@@ -89,41 +112,45 @@ export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
/>
</ParentModalProvider>
)}
<HStack alignItems="stretch" spacing="4" w="full">
<StarterPlanPricingCard
initialChatsLimitIndex={
workspace?.plan === Plan.STARTER
? data?.subscription.additionalChatsIndex
: 0
}
initialStorageLimitIndex={
workspace?.plan === Plan.STARTER
? data?.subscription.additionalStorageIndex
: 0
}
onPayClick={(props) =>
handlePayClick({ ...props, plan: Plan.STARTER })
}
isLoading={isUpdatingSubscription}
currency={data?.subscription.currency}
/>
{data && (
<Stack align="flex-end" spacing={6}>
<HStack>
<Text>Monthly</Text>
<Switch
isChecked={isYearly}
onChange={() => setIsYearly(!isYearly)}
/>
<HStack>
<Text>Yearly</Text>
<Tag colorScheme="blue">16% off</Tag>
</HStack>
</HStack>
<HStack alignItems="stretch" spacing="4" w="full">
<StarterPlanPricingCard
workspace={workspace}
currentSubscription={{ isYearly: data.subscription?.isYearly }}
onPayClick={(props) =>
handlePayClick({ ...props, plan: Plan.STARTER })
}
isYearly={isYearly}
isLoading={isUpdatingSubscription}
currency={data.subscription?.currency}
/>
<ProPlanPricingCard
workspace={workspace}
currentSubscription={{ isYearly: data.subscription?.isYearly }}
onPayClick={(props) =>
handlePayClick({ ...props, plan: Plan.PRO })
}
isYearly={isYearly}
isLoading={isUpdatingSubscription}
currency={data.subscription?.currency}
/>
</HStack>
</Stack>
)}
<ProPlanPricingCard
initialChatsLimitIndex={
workspace?.plan === Plan.PRO
? data?.subscription.additionalChatsIndex
: 0
}
initialStorageLimitIndex={
workspace?.plan === Plan.PRO
? data?.subscription.additionalStorageIndex
: 0
}
onPayClick={(props) => handlePayClick({ ...props, plan: Plan.PRO })}
isLoading={isUpdatingSubscription}
currency={data?.subscription.currency}
/>
</HStack>
<Text color="gray.500">
{scopedT('customLimit.preLink')}{' '}
<TextLink href={'https://typebot.io/enterprise-lead-form'} isExternal>

View File

@@ -1,5 +1,4 @@
import { Text, HStack, Link, Spinner, Stack, Heading } from '@chakra-ui/react'
import { useToast } from '@/hooks/useToast'
import { Text, HStack, Stack, Heading } from '@chakra-ui/react'
import { Plan } from '@typebot.io/prisma'
import React from 'react'
import { PlanTag } from './PlanTag'
@@ -10,25 +9,14 @@ import { useScopedI18n } from '@/locales'
type Props = {
workspace: Pick<Workspace, 'id' | 'plan' | 'stripeId'>
onCancelSuccess: () => void
}
export const CurrentSubscriptionSummary = ({
workspace,
onCancelSuccess,
}: Props) => {
export const CurrentSubscriptionSummary = ({ workspace }: Props) => {
const scopedT = useScopedI18n('billing.currentSubscription')
const { showToast } = useToast()
const { mutate: cancelSubscription, isLoading: isCancelling } =
trpc.billing.cancelSubscription.useMutation({
onError: (error) => {
showToast({
description: error.message,
})
},
onSuccess: onCancelSuccess,
})
const { data } = trpc.billing.getSubscription.useQuery({
workspaceId: workspace.id,
})
const isSubscribed =
(workspace.plan === Plan.STARTER || workspace.plan === Plan.PRO) &&
@@ -39,36 +27,15 @@ export const CurrentSubscriptionSummary = ({
<Heading fontSize="3xl">{scopedT('heading')}</Heading>
<HStack data-testid="current-subscription">
<Text>{scopedT('subheading')} </Text>
{isCancelling ? (
<Spinner color="gray.500" size="xs" />
) : (
<>
<PlanTag plan={workspace.plan} />
{isSubscribed && (
<Link
as="button"
color="gray.500"
textDecor="underline"
fontSize="sm"
onClick={() =>
cancelSubscription({ workspaceId: workspace.id })
}
>
{scopedT('cancelLink')}
</Link>
)}
</>
<PlanTag plan={workspace.plan} />
{data?.subscription?.cancelDate && (
<Text fontSize="sm">
(Will be cancelled on {data.subscription.cancelDate.toDateString()})
</Text>
)}
</HStack>
{isSubscribed && !isCancelling && (
<>
<Stack spacing="4">
<Text fontSize="sm">{scopedT('billingPortalDescription')}</Text>
<BillingPortalButton workspaceId={workspace.id} />
</Stack>
</>
)}
{isSubscribed && <BillingPortalButton workspaceId={workspace.id} />}
</Stack>
)
}

View File

@@ -28,6 +28,7 @@ export type PreCheckoutModalProps = {
additionalChats: number
additionalStorage: number
currency: 'eur' | 'usd'
isYearly: boolean
}
| undefined
existingCompany?: string

View File

@@ -15,10 +15,9 @@ import {
useColorModeValue,
} from '@chakra-ui/react'
import { ChevronLeftIcon } from '@/components/icons'
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
import { Plan } from '@typebot.io/prisma'
import { useEffect, useState } from 'react'
import { parseNumberWithCommas } from '@typebot.io/lib'
import { isDefined, parseNumberWithCommas } from '@typebot.io/lib'
import {
chatsLimit,
computePrice,
@@ -30,12 +29,23 @@ import {
import { FeaturesList } from './FeaturesList'
import { MoreInfoTooltip } from '@/components/MoreInfoTooltip'
import { useI18n, useScopedI18n } from '@/locales'
import { Workspace } from '@typebot.io/schemas'
type Props = {
initialChatsLimitIndex?: number
initialStorageLimitIndex?: number
workspace: Pick<
Workspace,
| 'additionalChatsIndex'
| 'additionalStorageIndex'
| 'plan'
| 'customChatsLimit'
| 'customStorageLimit'
>
currentSubscription: {
isYearly?: boolean
}
currency?: 'usd' | 'eur'
isLoading: boolean
isYearly: boolean
onPayClick: (props: {
selectedChatsLimitIndex: number
selectedStorageLimitIndex: number
@@ -43,15 +53,15 @@ type Props = {
}
export const ProPlanPricingCard = ({
initialChatsLimitIndex,
initialStorageLimitIndex,
workspace,
currentSubscription,
currency,
isLoading,
isYearly,
onPayClick,
}: Props) => {
const t = useI18n()
const scopedT = useScopedI18n('billing.pricingCard')
const { workspace } = useWorkspace()
const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] =
useState<number>()
const [selectedStorageLimitIndex, setSelectedStorageLimitIndex] =
@@ -59,20 +69,23 @@ export const ProPlanPricingCard = ({
useEffect(() => {
if (
selectedChatsLimitIndex === undefined &&
initialChatsLimitIndex !== undefined
isDefined(selectedChatsLimitIndex) ||
isDefined(selectedStorageLimitIndex)
)
setSelectedChatsLimitIndex(initialChatsLimitIndex)
if (
selectedStorageLimitIndex === undefined &&
initialStorageLimitIndex !== undefined
)
setSelectedStorageLimitIndex(initialStorageLimitIndex)
return
if (workspace.plan !== Plan.PRO) {
setSelectedChatsLimitIndex(0)
setSelectedStorageLimitIndex(0)
return
}
setSelectedChatsLimitIndex(workspace.additionalChatsIndex ?? 0)
setSelectedStorageLimitIndex(workspace.additionalStorageIndex ?? 0)
}, [
initialChatsLimitIndex,
initialStorageLimitIndex,
selectedChatsLimitIndex,
selectedStorageLimitIndex,
workspace.additionalChatsIndex,
workspace.additionalStorageIndex,
workspace?.plan,
])
const workspaceChatsLimit = workspace ? getChatsLimit(workspace) : undefined
@@ -81,14 +94,11 @@ export const ProPlanPricingCard = ({
: undefined
const isCurrentPlan =
chatsLimit[Plan.PRO].totalIncluded +
chatsLimit[Plan.PRO].increaseStep.amount *
(selectedChatsLimitIndex ?? 0) ===
workspaceChatsLimit &&
storageLimit[Plan.PRO].totalIncluded +
storageLimit[Plan.PRO].increaseStep.amount *
(selectedStorageLimitIndex ?? 0) ===
workspaceStorageLimit
chatsLimit[Plan.PRO].graduatedPrice[selectedChatsLimitIndex ?? 0]
.totalIncluded === workspaceChatsLimit &&
storageLimit[Plan.PRO].graduatedPrice[selectedStorageLimitIndex ?? 0]
.totalIncluded === workspaceStorageLimit &&
isYearly === currentSubscription?.isYearly
const getButtonLabel = () => {
if (
@@ -100,8 +110,8 @@ export const ProPlanPricingCard = ({
if (isCurrentPlan) return scopedT('upgradeButton.current')
if (
selectedChatsLimitIndex !== initialChatsLimitIndex ||
selectedStorageLimitIndex !== initialStorageLimitIndex
selectedChatsLimitIndex !== workspace.additionalChatsIndex ||
selectedStorageLimitIndex !== workspace.additionalStorageIndex
)
return t('update')
}
@@ -149,7 +159,11 @@ export const ProPlanPricingCard = ({
<Stack spacing="4" mt={2}>
<Heading fontSize="2xl">
{scopedT('heading', {
plan: <chakra.span color="blue.400">Pro</chakra.span>,
plan: (
<chakra.span color={useColorModeValue('blue.400', 'blue.300')}>
Pro
</chakra.span>
),
})}
</Heading>
<Text>{scopedT('pro.description')}</Text>
@@ -160,7 +174,8 @@ export const ProPlanPricingCard = ({
computePrice(
Plan.PRO,
selectedChatsLimitIndex ?? 0,
selectedStorageLimitIndex ?? 0
selectedStorageLimitIndex ?? 0,
isYearly ? 'yearly' : 'monthly'
) ?? NaN,
currency
)}
@@ -201,50 +216,21 @@ export const ProPlanPricingCard = ({
>
{selectedChatsLimitIndex !== undefined
? parseNumberWithCommas(
chatsLimit.PRO.totalIncluded +
chatsLimit.PRO.increaseStep.amount *
selectedChatsLimitIndex
chatsLimit.PRO.graduatedPrice[
selectedChatsLimitIndex
].totalIncluded
)
: undefined}
</MenuButton>
<MenuList>
{selectedChatsLimitIndex !== 0 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(0)}>
{parseNumberWithCommas(chatsLimit.PRO.totalIncluded)}
{chatsLimit.PRO.graduatedPrice.map((price, index) => (
<MenuItem
key={index}
onClick={() => setSelectedChatsLimitIndex(index)}
>
{parseNumberWithCommas(price.totalIncluded)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 1 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(1)}>
{parseNumberWithCommas(
chatsLimit.PRO.totalIncluded +
chatsLimit.PRO.increaseStep.amount
)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 2 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(2)}>
{parseNumberWithCommas(
chatsLimit.PRO.totalIncluded +
chatsLimit.PRO.increaseStep.amount * 2
)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 3 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(3)}>
{parseNumberWithCommas(
chatsLimit.PRO.totalIncluded +
chatsLimit.PRO.increaseStep.amount * 3
)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 4 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(4)}>
{parseNumberWithCommas(
chatsLimit.PRO.totalIncluded +
chatsLimit.PRO.increaseStep.amount * 4
)}
</MenuItem>
)}
))}
</MenuList>
</Menu>{' '}
{scopedT('chatsPerMonth')}
@@ -262,62 +248,21 @@ export const ProPlanPricingCard = ({
>
{selectedStorageLimitIndex !== undefined
? parseNumberWithCommas(
storageLimit.PRO.totalIncluded +
storageLimit.PRO.increaseStep.amount *
selectedStorageLimitIndex
storageLimit.PRO.graduatedPrice[
selectedStorageLimitIndex
].totalIncluded
)
: undefined}
</MenuButton>
<MenuList>
{selectedStorageLimitIndex !== 0 && (
{storageLimit.PRO.graduatedPrice.map((price, index) => (
<MenuItem
onClick={() => setSelectedStorageLimitIndex(0)}
key={index}
onClick={() => setSelectedStorageLimitIndex(index)}
>
{parseNumberWithCommas(
storageLimit.PRO.totalIncluded
)}
{parseNumberWithCommas(price.totalIncluded)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 1 && (
<MenuItem
onClick={() => setSelectedStorageLimitIndex(1)}
>
{parseNumberWithCommas(
storageLimit.PRO.totalIncluded +
storageLimit.PRO.increaseStep.amount
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 2 && (
<MenuItem
onClick={() => setSelectedStorageLimitIndex(2)}
>
{parseNumberWithCommas(
storageLimit.PRO.totalIncluded +
storageLimit.PRO.increaseStep.amount * 2
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 3 && (
<MenuItem
onClick={() => setSelectedStorageLimitIndex(3)}
>
{parseNumberWithCommas(
storageLimit.PRO.totalIncluded +
storageLimit.PRO.increaseStep.amount * 3
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 4 && (
<MenuItem
onClick={() => setSelectedStorageLimitIndex(4)}
>
{parseNumberWithCommas(
storageLimit.PRO.totalIncluded +
storageLimit.PRO.increaseStep.amount * 4
)}
</MenuItem>
)}
))}
</MenuList>
</Menu>{' '}
{scopedT('storageLimit')}

View File

@@ -11,10 +11,9 @@ import {
Text,
} from '@chakra-ui/react'
import { ChevronLeftIcon } from '@/components/icons'
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
import { Plan } from '@typebot.io/prisma'
import { useEffect, useState } from 'react'
import { parseNumberWithCommas } from '@typebot.io/lib'
import { isDefined, parseNumberWithCommas } from '@typebot.io/lib'
import {
chatsLimit,
computePrice,
@@ -26,12 +25,23 @@ import {
import { FeaturesList } from './FeaturesList'
import { MoreInfoTooltip } from '@/components/MoreInfoTooltip'
import { useI18n, useScopedI18n } from '@/locales'
import { Workspace } from '@typebot.io/schemas'
type Props = {
initialChatsLimitIndex?: number
initialStorageLimitIndex?: number
workspace: Pick<
Workspace,
| 'additionalChatsIndex'
| 'additionalStorageIndex'
| 'plan'
| 'customChatsLimit'
| 'customStorageLimit'
>
currentSubscription: {
isYearly?: boolean
}
currency?: 'eur' | 'usd'
isLoading?: boolean
isYearly: boolean
onPayClick: (props: {
selectedChatsLimitIndex: number
selectedStorageLimitIndex: number
@@ -39,15 +49,15 @@ type Props = {
}
export const StarterPlanPricingCard = ({
initialChatsLimitIndex,
initialStorageLimitIndex,
workspace,
currentSubscription,
isLoading,
currency,
isYearly,
onPayClick,
}: Props) => {
const t = useI18n()
const scopedT = useScopedI18n('billing.pricingCard')
const { workspace } = useWorkspace()
const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] =
useState<number>()
const [selectedStorageLimitIndex, setSelectedStorageLimitIndex] =
@@ -55,20 +65,23 @@ export const StarterPlanPricingCard = ({
useEffect(() => {
if (
selectedChatsLimitIndex === undefined &&
initialChatsLimitIndex !== undefined
isDefined(selectedChatsLimitIndex) ||
isDefined(selectedStorageLimitIndex)
)
setSelectedChatsLimitIndex(initialChatsLimitIndex)
if (
selectedStorageLimitIndex === undefined &&
initialStorageLimitIndex !== undefined
)
setSelectedStorageLimitIndex(initialStorageLimitIndex)
return
if (workspace.plan !== Plan.STARTER) {
setSelectedChatsLimitIndex(0)
setSelectedStorageLimitIndex(0)
return
}
setSelectedChatsLimitIndex(workspace.additionalChatsIndex ?? 0)
setSelectedStorageLimitIndex(workspace.additionalStorageIndex ?? 0)
}, [
initialChatsLimitIndex,
initialStorageLimitIndex,
selectedChatsLimitIndex,
selectedStorageLimitIndex,
workspace.additionalChatsIndex,
workspace.additionalStorageIndex,
workspace?.plan,
])
const workspaceChatsLimit = workspace ? getChatsLimit(workspace) : undefined
@@ -77,14 +90,11 @@ export const StarterPlanPricingCard = ({
: undefined
const isCurrentPlan =
chatsLimit[Plan.STARTER].totalIncluded +
chatsLimit[Plan.STARTER].increaseStep.amount *
(selectedChatsLimitIndex ?? 0) ===
workspaceChatsLimit &&
storageLimit[Plan.STARTER].totalIncluded +
storageLimit[Plan.STARTER].increaseStep.amount *
(selectedStorageLimitIndex ?? 0) ===
workspaceStorageLimit
chatsLimit[Plan.STARTER].graduatedPrice[selectedChatsLimitIndex ?? 0]
.totalIncluded === workspaceChatsLimit &&
storageLimit[Plan.STARTER].graduatedPrice[selectedStorageLimitIndex ?? 0]
.totalIncluded === workspaceStorageLimit &&
isYearly === currentSubscription?.isYearly
const getButtonLabel = () => {
if (
@@ -97,8 +107,9 @@ export const StarterPlanPricingCard = ({
if (isCurrentPlan) return scopedT('upgradeButton.current')
if (
selectedChatsLimitIndex !== initialChatsLimitIndex ||
selectedStorageLimitIndex !== initialStorageLimitIndex
selectedChatsLimitIndex !== workspace.additionalChatsIndex ||
selectedStorageLimitIndex !== workspace.additionalStorageIndex ||
isYearly !== currentSubscription?.isYearly
)
return t('update')
}
@@ -131,7 +142,8 @@ export const StarterPlanPricingCard = ({
computePrice(
Plan.STARTER,
selectedChatsLimitIndex ?? 0,
selectedStorageLimitIndex ?? 0
selectedStorageLimitIndex ?? 0,
isYearly ? 'yearly' : 'monthly'
) ?? NaN,
currency
)}
@@ -151,52 +163,21 @@ export const StarterPlanPricingCard = ({
>
{selectedChatsLimitIndex !== undefined
? parseNumberWithCommas(
chatsLimit.STARTER.totalIncluded +
chatsLimit.STARTER.increaseStep.amount *
selectedChatsLimitIndex
chatsLimit.STARTER.graduatedPrice[
selectedChatsLimitIndex
].totalIncluded
)
: undefined}
</MenuButton>
<MenuList>
{selectedChatsLimitIndex !== 0 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(0)}>
{parseNumberWithCommas(
chatsLimit.STARTER.totalIncluded
)}
{chatsLimit.STARTER.graduatedPrice.map((price, index) => (
<MenuItem
key={index}
onClick={() => setSelectedChatsLimitIndex(index)}
>
{parseNumberWithCommas(price.totalIncluded)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 1 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(1)}>
{parseNumberWithCommas(
chatsLimit.STARTER.totalIncluded +
chatsLimit.STARTER.increaseStep.amount
)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 2 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(2)}>
{parseNumberWithCommas(
chatsLimit.STARTER.totalIncluded +
chatsLimit.STARTER.increaseStep.amount * 2
)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 3 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(3)}>
{parseNumberWithCommas(
chatsLimit.STARTER.totalIncluded +
chatsLimit.STARTER.increaseStep.amount * 3
)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 4 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(4)}>
{parseNumberWithCommas(
chatsLimit.STARTER.totalIncluded +
chatsLimit.STARTER.increaseStep.amount * 4
)}
</MenuItem>
)}
))}
</MenuList>
</Menu>{' '}
{scopedT('chatsPerMonth')}
@@ -214,52 +195,21 @@ export const StarterPlanPricingCard = ({
>
{selectedStorageLimitIndex !== undefined
? parseNumberWithCommas(
storageLimit.STARTER.totalIncluded +
storageLimit.STARTER.increaseStep.amount *
selectedStorageLimitIndex
storageLimit.STARTER.graduatedPrice[
selectedStorageLimitIndex
].totalIncluded
)
: undefined}
</MenuButton>
<MenuList>
{selectedStorageLimitIndex !== 0 && (
<MenuItem onClick={() => setSelectedStorageLimitIndex(0)}>
{parseNumberWithCommas(
storageLimit.STARTER.totalIncluded
)}
{storageLimit.STARTER.graduatedPrice.map((price, index) => (
<MenuItem
key={index}
onClick={() => setSelectedStorageLimitIndex(index)}
>
{parseNumberWithCommas(price.totalIncluded)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 1 && (
<MenuItem onClick={() => setSelectedStorageLimitIndex(1)}>
{parseNumberWithCommas(
storageLimit.STARTER.totalIncluded +
storageLimit.STARTER.increaseStep.amount
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 2 && (
<MenuItem onClick={() => setSelectedStorageLimitIndex(2)}>
{parseNumberWithCommas(
storageLimit.STARTER.totalIncluded +
storageLimit.STARTER.increaseStep.amount * 2
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 3 && (
<MenuItem onClick={() => setSelectedStorageLimitIndex(3)}>
{parseNumberWithCommas(
storageLimit.STARTER.totalIncluded +
storageLimit.STARTER.increaseStep.amount * 3
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 4 && (
<MenuItem onClick={() => setSelectedStorageLimitIndex(4)}>
{parseNumberWithCommas(
storageLimit.STARTER.totalIncluded +
storageLimit.STARTER.increaseStep.amount * 4
)}
</MenuItem>
)}
))}
</MenuList>
</Menu>{' '}
{scopedT('storageLimit')}

View File

@@ -1,16 +1,19 @@
import { Plan } from '@typebot.io/prisma'
import {
getChatsLimit,
getStorageLimit,
priceIds,
} from '@typebot.io/lib/pricing'
export const parseSubscriptionItems = (
plan: Plan,
plan: 'STARTER' | 'PRO',
additionalChats: number,
additionalStorage: number
) =>
[
additionalStorage: number,
isYearly: boolean
) => {
const frequency = isYearly ? 'yearly' : 'monthly'
return [
{
price:
plan === Plan.STARTER
? process.env.STRIPE_STARTER_PRICE_ID
: process.env.STRIPE_PRO_PRICE_ID,
price: priceIds[plan].base[frequency],
quantity: 1,
},
]
@@ -18,8 +21,12 @@ export const parseSubscriptionItems = (
additionalChats > 0
? [
{
price: process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID,
quantity: additionalChats,
price: priceIds[plan].chats[frequency],
quantity: getChatsLimit({
plan,
additionalChatsIndex: additionalChats,
customChatsLimit: null,
}),
},
]
: []
@@ -28,9 +35,14 @@ export const parseSubscriptionItems = (
additionalStorage > 0
? [
{
price: process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID,
quantity: additionalStorage,
price: priceIds[plan].storage[frequency],
quantity: getStorageLimit({
plan,
additionalStorageIndex: additionalStorage,
customStorageLimit: null,
}),
},
]
: []
)
}

View File

@@ -26,10 +26,11 @@ export const DashboardPage = () => {
useState<PreCheckoutModalProps['selectedSubscription']>()
useEffect(() => {
const { subscribePlan, chats, storage } = query as {
const { subscribePlan, chats, storage, isYearly } = query as {
subscribePlan: Plan | undefined
chats: string | undefined
storage: string | undefined
isYearly: string | undefined
}
if (workspace && subscribePlan && user && workspace.plan === 'FREE') {
setIsLoading(true)
@@ -39,6 +40,7 @@ export const DashboardPage = () => {
additionalChats: chats ? parseInt(chats) : 0,
additionalStorage: storage ? parseInt(storage) : 0,
currency: guessIfUserIsEuropean() ? 'eur' : 'usd',
isYearly: isYearly === 'false' ? false : true,
})
}
}, [query, user, workspace])

View File

@@ -103,8 +103,6 @@ export default {
'billing.currentSubscription.heading': 'Subscription',
'billing.currentSubscription.subheading': 'Current workspace subscription:',
'billing.currentSubscription.cancelLink': 'Cancel my subscription',
'billing.currentSubscription.billingPortalDescription':
'Need to change payment method or billing information? Head over to your billing portal:',
'billing.invoices.heading': 'Invoices',
'billing.invoices.empty': 'No invoices found for this workspace.',
'billing.invoices.paidAt': 'Paid at',

View File

@@ -109,8 +109,6 @@ export default defineLocale({
'billing.currentSubscription.heading': 'Abonnement',
'billing.currentSubscription.subheading': 'Abonnement actuel du workspace :',
'billing.currentSubscription.cancelLink': "Annuler l'abonnement",
'billing.currentSubscription.billingPortalDescription':
'Besoin de changer votre mode de paiement ou vos informations de facturation ? Rendez-vous sur votre portail de facturation :',
'billing.invoices.heading': 'Factures',
'billing.invoices.empty': 'Aucune facture trouvée pour ce workspace.',
'billing.invoices.paidAt': 'Payé le',

View File

@@ -109,8 +109,6 @@ export default defineLocale({
'billing.currentSubscription.subheading':
'Assinatura atual do espaço de trabalho:',
'billing.currentSubscription.cancelLink': 'Cancelar minha assinatura',
'billing.currentSubscription.billingPortalDescription':
'Precisa alterar o método de pagamento ou as informações de cobrança? Acesse seu portal de cobrança:',
'billing.invoices.heading': 'Faturas',
'billing.invoices.empty':
'Nenhuma fatura encontrada para este espaço de trabalho.',

View File

@@ -52,6 +52,19 @@ export const addSubscriptionToWorkspace = async (
...metadata,
},
})
return stripeId
}
export const cancelSubscription = async (stripeId: string) => {
const currentSubscriptionId = (
await stripe.subscriptions.list({
customer: stripeId,
})
).data.shift()?.id
if (currentSubscriptionId)
await stripe.subscriptions.update(currentSubscriptionId, {
cancel_at_period_end: true,
})
}
export const createCollaboration = (