2
0

(billing) Automatic usage-based billing (#924)

BREAKING CHANGE: Stripe environment variables simplified. Check out the
new configs to adapt your existing system.

Closes #906





<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
### Summary by CodeRabbit

**New Features:**
- Introduced a usage-based billing system, providing more flexibility
and options for users.
- Integrated with Stripe for a smoother and more secure payment process.
- Enhanced the user interface with improvements to the billing,
workspace, and pricing pages for a more intuitive experience.

**Improvements:**
- Simplified the billing logic, removing additional chats and yearly
billing for a more streamlined user experience.
- Updated email notifications to keep users informed about their usage
and limits.
- Improved pricing and currency formatting for better clarity and
understanding.

**Testing:**
- Updated tests and specifications to ensure the reliability of new
features and improvements.

**Note:** These changes aim to provide a more flexible and user-friendly
billing system, with clearer pricing and improved notifications. Users
should find the new system more intuitive and easier to navigate.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Baptiste Arnaud
2023-10-17 08:03:30 +02:00
committed by GitHub
parent a8c2deb258
commit 797751b418
55 changed files with 1589 additions and 1497 deletions

View File

@ -0,0 +1,91 @@
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
Stack,
ModalFooter,
Heading,
Table,
TableContainer,
Tbody,
Td,
Th,
Thead,
Tr,
} from '@chakra-ui/react'
import { proChatTiers } from '@typebot.io/lib/billing/constants'
import { formatPrice } from '@typebot.io/lib/billing/formatPrice'
type Props = {
isOpen: boolean
onClose: () => void
}
export const ChatsProTiersModal = ({ isOpen, onClose }: Props) => {
return (
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>
<Heading size="lg">Chats pricing table</Heading>
</ModalHeader>
<ModalCloseButton />
<ModalBody as={Stack} spacing="6">
<TableContainer>
<Table variant="simple">
<Thead>
<Tr>
<Th isNumeric>Max chats</Th>
<Th isNumeric>Price per month</Th>
<Th isNumeric>Price per 1k chats</Th>
</Tr>
</Thead>
<Tbody>
{proChatTiers.map((tier, index) => {
const pricePerMonth =
proChatTiers
.slice(0, index + 1)
.reduce(
(acc, slicedTier) =>
acc + (slicedTier.flat_amount ?? 0),
0
) / 100
return (
<Tr key={tier.up_to}>
<Td isNumeric>
{tier.up_to === 'inf'
? '2,000,000+'
: tier.up_to.toLocaleString()}
</Td>
<Td isNumeric>
{index === 0 ? 'included' : formatPrice(pricePerMonth)}
</Td>
<Td isNumeric>
{index === proChatTiers.length - 1
? formatPrice(4.42, { maxFractionDigits: 2 })
: index === 0
? 'included'
: formatPrice(
(((pricePerMonth * 100) /
((tier.up_to as number) -
(proChatTiers.at(0)?.up_to as number))) *
1000) /
100,
{ maxFractionDigits: 2 }
)}
</Td>
</Tr>
)
})}
</Tbody>
</Table>
</TableContainer>
</ModalBody>
<ModalFooter />
</ModalContent>
</Modal>
)
}

View File

@ -1,88 +1,74 @@
import { Heading, VStack, SimpleGrid, Stack, Text } from '@chakra-ui/react'
import { Heading, VStack, Stack, Text, Wrap, WrapItem } from '@chakra-ui/react'
export const Faq = () => (
<VStack w="full" spacing="10">
<Heading textAlign="center">Frequently asked questions</Heading>
<SimpleGrid columns={[1, 2]} spacing={10}>
<Stack borderWidth={1} p="8" rounded="lg" spacing={4}>
<Heading as="h2" fontSize="2xl">
What is considered a monthly chat?
</Heading>
<Text>
A chat is counted whenever a user starts a discussion. It is
independant of the number of messages he sends and receives. For
example if a user starts a discussion and sends 10 messages to the
bot, it will count as 1 chat. If the user chats again later and its
session is remembered, it will not be counted as a new chat. <br />
<br />
An easy way to think about it: 1 chat equals to a row in your Results
table
</Text>
</Stack>
<Stack borderWidth={1} p="8" rounded="lg" spacing={4}>
<Heading as="h2" fontSize="2xl">
What happens once I reach the monthly chats limit?
</Heading>
<Text>
You will receive a heads up email when you reach 80% of your monthly
limit. Once you have reached the limit, you will receive another email
alert. Your bots will continue to run. You will be kindly asked to
upgrade your subscription. If you don&apos;t provide an answer after
~48h, your bots will be closed for the remaining of the month. For a
FREE workspace, If you exceed 600 chats, your bots will be
automatically closed.
</Text>
</Stack>
<Stack borderWidth={1} p="8" rounded="lg" spacing={4}>
<Heading as="h2" fontSize="2xl">
What is considered as storage?
</Heading>
<Text>
You accumulate storage for every file that your user upload into your
bot. If you delete the associated result, it will free up the used
space.
</Text>
</Stack>
<Stack borderWidth={1} p="8" rounded="lg" spacing={4}>
<Heading as="h2" fontSize="2xl">
What happens once I reach the storage limit?
</Heading>
<Text>
When you exceed the storage size included in your plan, you will
receive a heads up by email. There won&apos;t be any immediate
additional charges and your bots will continue to store new files. If
you continue to exceed the limit, you will be kindly asked you to
upgrade your subscription.
</Text>
</Stack>
<Stack borderWidth={1} p="8" rounded="lg" spacing={4}>
<Heading as="h2" fontSize="2xl">
Can I cancel or change my subscription any time?
</Heading>
<Text>
Yes, you can cancel, upgrade or downgrade your subscription at any
time. There is no minimum time commitment or lock-in.
<br />
<br />
When you upgrade or downgrade your subscription, you&apos;ll get
access to the new options right away. Your next invoice will have a
prorated amount.
</Text>
</Stack>
<Stack borderWidth={1} p="8" rounded="lg" spacing={4}>
<Heading as="h2" fontSize="2xl">
Do you offer annual payments?
</Heading>
<Text>
Yes. Starter and Pro plans can be purchased with monthly or annual
billing.
<br />
<br />
Annual plans are cheaper and give you a 16% discount compared to
monthly payments. Enterprise plans are only available with annual
billing.
</Text>
</Stack>
</SimpleGrid>
<Wrap spacing={10}>
<WrapItem maxW="500px">
<Stack borderWidth={1} p="8" rounded="lg" spacing={4}>
<Heading as="h2" fontSize="2xl">
What is considered a monthly chat?
</Heading>
<Text>
A chat is counted whenever a user starts a discussion. It is
independant of the number of messages he sends and receives. For
example if a user starts a discussion and sends 10 messages to the
bot, it will count as 1 chat. If the user chats again later and its
session is remembered, it will not be counted as a new chat. <br />
<br />
An easy way to think about it: 1 chat equals to a row in your
Results table
</Text>
</Stack>
</WrapItem>
<WrapItem maxW="500px">
<Stack borderWidth={1} p="8" rounded="lg" spacing={4}>
<Heading as="h2" fontSize="2xl">
What happens once I reach the included chats limit?
</Heading>
<Text>
That&apos;s amazing, your bots are working full speed. 🚀
<br />
<br />
You will first receive a heads up email when you reach 80% of your
included limit. Once you have reached 100%, you will receive another
email notification.
<br />
<br />
After that, your chat limit be automatically upgraded to the next
tier.
</Text>
</Stack>
</WrapItem>
<WrapItem maxW="500px">
<Stack borderWidth={1} p="8" rounded="lg" spacing={4}>
<Heading as="h2" fontSize="2xl">
Can I cancel or change my subscription any time?
</Heading>
<Text>
Yes, you can cancel, upgrade or downgrade your subscription at any
time. There is no minimum time commitment or lock-in.
<br />
<br />
When you upgrade or downgrade your subscription, you&apos;ll get
access to the new options right away. Your next invoice will have a
prorated amount.
</Text>
</Stack>
</WrapItem>
<WrapItem maxW="500px">
<Stack borderWidth={1} p="8" rounded="lg" spacing={4}>
<Heading as="h2" fontSize="2xl">
Do you offer annual payments?
</Heading>
<Text>
No, because subscriptions pricing is based on chats usage, we can
only offer monthly plans.
</Text>
</Stack>
</WrapItem>
</Wrap>
</VStack>
)

View File

@ -3,7 +3,7 @@ import { HelpCircleIcon } from 'assets/icons/HelpCircleIcon'
import Link from 'next/link'
import React from 'react'
import { PricingCard } from './PricingCard'
import { chatsLimit } from '@typebot.io/lib/pricing'
import { chatsLimits } from '@typebot.io/lib/billing/constants'
export const FreePlanCard = () => (
<PricingCard
@ -14,9 +14,7 @@ export const FreePlanCard = () => (
'Unlimited typebots',
<>
<Text>
<chakra.span fontWeight="bold">
{chatsLimit.FREE.totalIncluded}
</chakra.span>{' '}
<chakra.span fontWeight="bold">{chatsLimits.FREE}</chakra.span>{' '}
chats/month
</Text>
&nbsp;

View File

@ -19,15 +19,19 @@ import { HelpCircleIcon } from 'assets/icons/HelpCircleIcon'
import { Plan } from '@typebot.io/prisma'
import Link from 'next/link'
import React from 'react'
import {
chatsLimit,
formatPrice,
prices,
seatsLimit,
} from '@typebot.io/lib/pricing'
import { parseNumberWithCommas } from '@typebot.io/lib'
import {
chatsLimits,
prices,
seatsLimits,
} from '@typebot.io/lib/billing/constants'
import { formatPrice } from '@typebot.io/lib/billing/formatPrice'
export const PlanComparisonTables = () => (
type Props = {
onChatsTiersClick: () => void
}
export const PlanComparisonTables = ({ onChatsTiersClick }: Props) => (
<Stack spacing="12">
<TableContainer>
<Table>
@ -50,32 +54,23 @@ export const PlanComparisonTables = () => (
</Tr>
<Tr>
<Td>Chats</Td>
<Td>{chatsLimit.FREE.totalIncluded} / month</Td>
<Td>
{parseNumberWithCommas(
chatsLimit.STARTER.graduatedPrice[0].totalIncluded
)}{' '}
/ month
</Td>
<Td>
{parseNumberWithCommas(
chatsLimit.PRO.graduatedPrice[0].totalIncluded
)}{' '}
/ month
</Td>
<Td>{chatsLimits.FREE} / month</Td>
<Td>{parseNumberWithCommas(chatsLimits.STARTER)} / month</Td>
<Td>{parseNumberWithCommas(chatsLimits.PRO)} / month</Td>
</Tr>
<Tr>
<Td>Additional Chats</Td>
<Td />
<Td>{formatPrice(10)} per 500 chats</Td>
<Td>
{formatPrice(chatsLimit.STARTER.graduatedPrice[1].price)} per{' '}
{chatsLimit.STARTER.graduatedPrice[1].totalIncluded -
chatsLimit.STARTER.graduatedPrice[0].totalIncluded}
</Td>
<Td>
{formatPrice(chatsLimit.PRO.graduatedPrice[1].price)} per{' '}
{chatsLimit.PRO.graduatedPrice[1].totalIncluded -
chatsLimit.PRO.graduatedPrice[0].totalIncluded}
<Button
variant="outline"
size="xs"
onClick={onChatsTiersClick}
colorScheme="gray"
>
See tiers
</Button>
</Td>
</Tr>
<Tr>
@ -87,8 +82,8 @@ export const PlanComparisonTables = () => (
<Tr>
<Td>Members</Td>
<Td>Just you</Td>
<Td>{seatsLimit.STARTER.totalIncluded} seats</Td>
<Td>{seatsLimit.PRO.totalIncluded} seats</Td>
<Td>{seatsLimits.STARTER} seats</Td>
<Td>{seatsLimits.PRO} seats</Td>
</Tr>
<Tr>
<Td>Guests</Td>
@ -276,6 +271,14 @@ export const PlanComparisonTables = () => (
<CheckIcon />
</Td>
</Tr>
<Tr>
<Td>WhatsApp integration</Td>
<Td />
<Td />
<Td>
<CheckIcon />
</Td>
</Tr>
<Tr>
<Td>Custom domains</Td>
<Td />

View File

@ -9,9 +9,9 @@ import {
VStack,
} from '@chakra-ui/react'
import * as React from 'react'
import { formatPrice } from '@typebot.io/lib/pricing'
import { CheckCircleIcon } from '../../../assets/icons/CheckCircleIcon'
import { Card, CardProps } from './Card'
import { formatPrice } from '@typebot.io/lib/billing/formatPrice'
export interface PricingCardData {
features: React.ReactNode[]

View File

@ -4,107 +4,75 @@ import {
Text,
Button,
HStack,
Menu,
MenuButton,
MenuItem,
MenuList,
Stack,
Link,
} from '@chakra-ui/react'
import { ChevronDownIcon } from 'assets/icons/ChevronDownIcon'
import { HelpCircleIcon } from 'assets/icons/HelpCircleIcon'
import { Plan } from '@typebot.io/prisma'
import Link from 'next/link'
import React, { useState } from 'react'
import { parseNumberWithCommas } from '@typebot.io/lib'
import { chatsLimit, computePrice, seatsLimit } from '@typebot.io/lib/pricing'
import React from 'react'
import { PricingCard } from './PricingCard'
import { prices, seatsLimits } from '@typebot.io/lib/billing/constants'
type Props = {
isYearly: boolean
onChatsTiersClick: () => void
}
export const ProPlanCard = ({ isYearly }: Props) => {
const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] =
useState<number>(0)
const price =
computePrice(
Plan.PRO,
selectedChatsLimitIndex ?? 0,
isYearly ? 'yearly' : 'monthly'
) ?? NaN
return (
<PricingCard
data={{
price,
name: 'Pro',
featureLabel: 'Everything in Personal, plus:',
features: [
<Text key="seats">
<chakra.span fontWeight="bold">
{seatsLimit.PRO.totalIncluded} seats
</chakra.span>{' '}
included
</Text>,
<HStack key="chats" spacing={1.5}>
<Menu>
<MenuButton
as={Button}
rightIcon={<ChevronDownIcon />}
size="sm"
variant="outline"
isLoading={selectedChatsLimitIndex === undefined}
>
{selectedChatsLimitIndex !== undefined
? parseNumberWithCommas(
chatsLimit.PRO.graduatedPrice[selectedChatsLimitIndex]
.totalIncluded
)
: undefined}
</MenuButton>
<MenuList>
{chatsLimit.PRO.graduatedPrice.map((price, index) => (
<MenuItem
key={index}
onClick={() => setSelectedChatsLimitIndex(index)}
>
{parseNumberWithCommas(price.totalIncluded)}
</MenuItem>
))}
</MenuList>
</Menu>{' '}
<Text>chats/mo</Text>
export const ProPlanCard = ({ onChatsTiersClick }: Props) => (
<PricingCard
data={{
price: prices.PRO,
name: 'Pro',
featureLabel: 'Everything in Personal, plus:',
features: [
<Text key="seats">
<chakra.span fontWeight="bold">{seatsLimits.PRO} seats</chakra.span>{' '}
included
</Text>,
<Stack key="chats" spacing={0}>
<HStack spacing={1.5}>
<Text>10,000 chats/mo</Text>
<Tooltip
hasArrow
placement="top"
label="A chat is counted whenever a user starts a discussion. It is
independant of the number of messages he sends and receives."
independant of the number of messages he sends and receives."
>
<chakra.span cursor="pointer" h="7">
<HelpCircleIcon />
</chakra.span>
</Tooltip>
</HStack>,
'WhatsApp integration',
'Custom domains',
'In-depth analytics',
],
}}
borderWidth="3px"
borderColor="blue.200"
button={
<Button
as={Link}
href={`https://app.typebot.io/register?subscribePlan=${Plan.PRO}&chats=${selectedChatsLimitIndex}&isYearly=${isYearly}`}
colorScheme="blue"
size="lg"
w="full"
fontWeight="extrabold"
py={{ md: '8' }}
>
Subscribe now
</Button>
}
/>
)
}
</HStack>
<Text fontSize="sm" color="gray.400">
Extra chats:{' '}
<Button
variant="outline"
size="xs"
colorScheme="gray"
onClick={onChatsTiersClick}
>
See tiers
</Button>
</Text>
</Stack>,
'WhatsApp integration',
'Custom domains',
'In-depth analytics',
],
}}
borderWidth="3px"
borderColor="blue.200"
button={
<Button
as={Link}
href={`https://app.typebot.io/register?subscribePlan=${Plan.PRO}`}
colorScheme="blue"
size="lg"
w="full"
fontWeight="extrabold"
py={{ md: '8' }}
>
Subscribe now
</Button>
}
/>
)

View File

@ -1,87 +1,43 @@
import {
chakra,
Tooltip,
Text,
HStack,
Menu,
MenuButton,
Button,
MenuItem,
MenuList,
} from '@chakra-ui/react'
import { ChevronDownIcon } from 'assets/icons/ChevronDownIcon'
import { chakra, Tooltip, Text, HStack, Button, Stack } from '@chakra-ui/react'
import { HelpCircleIcon } from 'assets/icons/HelpCircleIcon'
import { Plan } from '@typebot.io/prisma'
import Link from 'next/link'
import React, { useState } from 'react'
import { parseNumberWithCommas } from '@typebot.io/lib'
import { chatsLimit, computePrice, seatsLimit } from '@typebot.io/lib/pricing'
import React from 'react'
import { PricingCard } from './PricingCard'
import { prices, seatsLimits } from '@typebot.io/lib/billing/constants'
type Props = {
isYearly: boolean
}
export const StarterPlanCard = ({ isYearly }: Props) => {
const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] =
useState<number>(0)
const price =
computePrice(
Plan.STARTER,
selectedChatsLimitIndex ?? 0,
isYearly ? 'yearly' : 'monthly'
) ?? NaN
export const StarterPlanCard = () => {
return (
<PricingCard
data={{
price,
price: prices.STARTER,
name: 'Starter',
featureLabel: 'Everything in Personal, plus:',
features: [
<Text key="seats">
<chakra.span fontWeight="bold">
{seatsLimit.STARTER.totalIncluded} seats
{seatsLimits.STARTER} seats
</chakra.span>{' '}
included
</Text>,
<HStack key="chats" spacing={1.5}>
<Menu>
<MenuButton
as={Button}
rightIcon={<ChevronDownIcon />}
size="sm"
variant="outline"
colorScheme="orange"
>
{parseNumberWithCommas(
chatsLimit.STARTER.graduatedPrice[selectedChatsLimitIndex]
.totalIncluded
)}
</MenuButton>
<MenuList>
{chatsLimit.STARTER.graduatedPrice.map((price, index) => (
<MenuItem
key={index}
onClick={() => setSelectedChatsLimitIndex(index)}
>
{parseNumberWithCommas(price.totalIncluded)}
</MenuItem>
))}
</MenuList>
</Menu>{' '}
<Text>chats/mo</Text>
<Tooltip
hasArrow
placement="top"
label="A chat is counted whenever a user starts a discussion. It is
<Stack key="chats" spacing={0}>
<HStack spacing={1.5}>
<Text>2,000 chats/mo</Text>
<Tooltip
hasArrow
placement="top"
label="A chat is counted whenever a user starts a discussion. It is
independant of the number of messages he sends and receives."
>
<chakra.span cursor="pointer" h="7">
<HelpCircleIcon />
</chakra.span>
</Tooltip>
</HStack>,
>
<chakra.span cursor="pointer" h="7">
<HelpCircleIcon />
</chakra.span>
</Tooltip>
</HStack>
<Text fontSize="sm" color="gray.400">
Extra chats: $10 per 500
</Text>
</Stack>,
'Branding removed',
'Collect files from users',
'Create folders',
@ -92,7 +48,7 @@ export const StarterPlanCard = ({ isYearly }: Props) => {
button={
<Button
as={Link}
href={`https://app.typebot.io/register?subscribePlan=${Plan.STARTER}&chats=${selectedChatsLimitIndex}&isYearly=${isYearly}`}
href={`https://app.typebot.io/register?subscribePlan=${Plan.STARTER}`}
colorScheme="orange"
size="lg"
w="full"

View File

@ -6,15 +6,13 @@ import {
VStack,
Text,
HStack,
Switch,
Tag,
useDisclosure,
} from '@chakra-ui/react'
import { Footer } from 'components/common/Footer'
import { Header } from 'components/common/Header/Header'
import { SocialMetaTags } from 'components/common/SocialMetaTags'
import { BackgroundPolygons } from 'components/Homepage/Hero/BackgroundPolygons'
import { PlanComparisonTables } from 'components/PricingPage/PlanComparisonTables'
import { useState } from 'react'
import { StripeClimateLogo } from 'assets/logos/StripeClimateLogo'
import { FreePlanCard } from 'components/PricingPage/FreePlanCard'
import { StarterPlanCard } from 'components/PricingPage/StarterPlanCard'
@ -22,12 +20,14 @@ import { ProPlanCard } from 'components/PricingPage/ProPlanCard'
import { TextLink } from 'components/common/TextLink'
import { EnterprisePlanCard } from 'components/PricingPage/EnterprisePlanCard'
import { Faq } from 'components/PricingPage/Faq'
import { ChatsProTiersModal } from 'components/PricingPage/ChatsProTiersModal'
const Pricing = () => {
const [isYearly, setIsYearly] = useState(true)
const { isOpen, onOpen, onClose } = useDisclosure()
return (
<Stack overflowX="hidden" bgColor="gray.900">
<ChatsProTiersModal isOpen={isOpen} onClose={onClose} />
<Flex
pos="relative"
flexDir="column"
@ -87,30 +87,16 @@ const Pricing = () => {
</TextLink>
</Text>
</HStack>
<Stack align="flex-end" maxW="1200px" w="full" spacing={4}>
<HStack>
<Text>Monthly</Text>
<Switch
isChecked={isYearly}
onChange={() => setIsYearly(!isYearly)}
/>
<HStack>
<Text>Yearly</Text>
<Tag colorScheme="blue">16% off</Tag>
</HStack>
</HStack>
<Stack
direction={['column', 'row']}
alignItems={['stretch']}
spacing={10}
w="full"
maxW="1200px"
>
<FreePlanCard />
<StarterPlanCard isYearly={isYearly} />
<ProPlanCard isYearly={isYearly} />
</Stack>
<Stack
direction={['column', 'row']}
alignItems={['stretch']}
spacing={10}
w="full"
maxW="1200px"
>
<FreePlanCard />
<StarterPlanCard />
<ProPlanCard onChatsTiersClick={onOpen} />
</Stack>
<EnterprisePlanCard />
@ -119,7 +105,7 @@ const Pricing = () => {
<VStack maxW="1200px" w="full" spacing={[12, 20]} px="4">
<Stack w="full" spacing={10} display={['none', 'flex']}>
<Heading>Compare plans & features</Heading>
<PlanComparisonTables />
<PlanComparisonTables onChatsTiersClick={onOpen} />
</Stack>
<Faq />
</VStack>