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

@ -1,4 +1,4 @@
name: Send chats limit alert emails
name: Check and report chats usage
on:
schedule:
@ -22,8 +22,9 @@ jobs:
SMTP_HOST: '${{ secrets.SMTP_HOST }}'
SMTP_PORT: '${{ secrets.SMTP_PORT }}'
NEXT_PUBLIC_SMTP_FROM: '${{ secrets.NEXT_PUBLIC_SMTP_FROM }}'
STRIPE_SECRET_KEY: '${{ secrets.STRIPE_SECRET_KEY }}'
steps:
- uses: actions/checkout@v2
- uses: pnpm/action-setup@v2.2.2
- run: pnpm i --frozen-lockfile
- run: pnpm turbo run sendAlertEmails
- run: pnpm turbo run checkAndReportChatsUsage

View File

@ -1,24 +0,0 @@
name: Send total results daily digest
on:
schedule:
- cron: '0 5 * * *'
jobs:
send:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./packages/scripts
env:
DATABASE_URL: '${{ secrets.DATABASE_URL }}'
ENCRYPTION_SECRET: '${{ secrets.ENCRYPTION_SECRET }}'
NEXTAUTH_URL: 'http://localhost:3000'
NEXT_PUBLIC_VIEWER_URL: 'http://localhost:3001'
TELEMETRY_WEBHOOK_URL: '${{ secrets.TELEMETRY_WEBHOOK_URL }}'
TELEMETRY_WEBHOOK_BEARER_TOKEN: '${{ secrets.TELEMETRY_WEBHOOK_BEARER_TOKEN }}'
steps:
- uses: actions/checkout@v2
- uses: pnpm/action-setup@v2.2.2
- run: pnpm i --frozen-lockfile
- run: pnpm turbo run telemetry:sendTotalResultsDigest

View File

@ -4,7 +4,6 @@ import { TRPCError } from '@trpc/server'
import { Plan } from '@typebot.io/prisma'
import Stripe from 'stripe'
import { z } from 'zod'
import { parseSubscriptionItems } from '../helpers/parseSubscriptionItems'
import { isAdminWriteWorkspaceForbidden } from '@/features/workspace/helpers/isAdminWriteWorkspaceForbidden'
import { env } from '@typebot.io/env'
@ -26,14 +25,12 @@ export const createCheckoutSession = authenticatedProcedure
currency: z.enum(['usd', 'eur']),
plan: z.enum([Plan.STARTER, Plan.PRO]),
returnUrl: z.string(),
additionalChats: z.number(),
vat: z
.object({
type: z.string(),
value: z.string(),
})
.optional(),
isYearly: z.boolean(),
})
)
.output(
@ -43,17 +40,7 @@ export const createCheckoutSession = authenticatedProcedure
)
.mutation(
async ({
input: {
vat,
email,
company,
workspaceId,
currency,
plan,
returnUrl,
additionalChats,
isYearly,
},
input: { vat, email, company, workspaceId, currency, plan, returnUrl },
ctx: { user },
}) => {
if (!env.STRIPE_SECRET_KEY)
@ -116,8 +103,6 @@ export const createCheckoutSession = authenticatedProcedure
currency,
plan,
returnUrl,
additionalChats,
isYearly,
})
if (!checkoutUrl)
@ -138,22 +123,12 @@ type Props = {
currency: 'usd' | 'eur'
plan: 'STARTER' | 'PRO'
returnUrl: string
additionalChats: number
isYearly: boolean
userId: string
}
export const createCheckoutSessionUrl =
(stripe: Stripe) =>
async ({
customerId,
workspaceId,
currency,
plan,
returnUrl,
additionalChats,
isYearly,
}: Props) => {
async ({ customerId, workspaceId, currency, plan, returnUrl }: Props) => {
const session = await stripe.checkout.sessions.create({
success_url: `${returnUrl}?stripe=${plan}&success=true`,
cancel_url: `${returnUrl}?stripe=cancel`,
@ -167,12 +142,25 @@ export const createCheckoutSessionUrl =
metadata: {
workspaceId,
plan,
additionalChats,
},
currency,
billing_address_collection: 'required',
automatic_tax: { enabled: true },
line_items: parseSubscriptionItems(plan, additionalChats, isYearly),
line_items: [
{
price:
plan === 'STARTER'
? env.STRIPE_STARTER_PRICE_ID
: env.STRIPE_PRO_PRICE_ID,
quantity: 1,
},
{
price:
plan === 'STARTER'
? env.STRIPE_STARTER_CHATS_PRICE_ID
: env.STRIPE_PRO_CHATS_PRICE_ID,
},
],
})
return session.url

View File

@ -5,7 +5,6 @@ import Stripe from 'stripe'
import { z } from 'zod'
import { subscriptionSchema } from '@typebot.io/schemas/features/billing/subscription'
import { isReadWorkspaceFobidden } from '@/features/workspace/helpers/isReadWorkspaceFobidden'
import { priceIds } from '@typebot.io/lib/api/pricing'
import { env } from '@typebot.io/env'
export const getSubscription = authenticatedProcedure
@ -75,15 +74,14 @@ export const getSubscription = authenticatedProcedure
return {
subscription: {
currentBillingPeriod:
subscriptionSchema.shape.currentBillingPeriod.parse({
start: new Date(currentSubscription.current_period_start),
end: new Date(currentSubscription.current_period_end),
}),
status: subscriptionSchema.shape.status.parse(
currentSubscription.status
),
isYearly: currentSubscription.items.data.some((item) => {
return (
priceIds.STARTER.chats.yearly === item.price.id ||
priceIds.PRO.chats.yearly === item.price.id
)
}),
currency: currentSubscription.currency as 'usd' | 'eur',
cancelDate: currentSubscription.cancel_at
? new Date(currentSubscription.cancel_at * 1000)
@ -91,8 +89,3 @@ export const getSubscription = authenticatedProcedure
},
}
})
export const chatPriceIds = [priceIds.STARTER.chats.monthly]
.concat(priceIds.STARTER.chats.yearly)
.concat(priceIds.PRO.chats.monthly)
.concat(priceIds.PRO.chats.yearly)

View File

@ -3,6 +3,8 @@ import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { z } from 'zod'
import { isReadWorkspaceFobidden } from '@/features/workspace/helpers/isReadWorkspaceFobidden'
import { env } from '@typebot.io/env'
import Stripe from 'stripe'
export const getUsage = authenticatedProcedure
.meta({
@ -19,13 +21,15 @@ export const getUsage = authenticatedProcedure
workspaceId: z.string(),
})
)
.output(z.object({ totalChatsUsed: z.number() }))
.output(z.object({ totalChatsUsed: z.number(), resetsAt: z.date() }))
.query(async ({ input: { workspaceId }, ctx: { user } }) => {
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
},
select: {
stripeId: true,
plan: true,
members: {
select: {
userId: true,
@ -42,19 +46,63 @@ export const getUsage = authenticatedProcedure
message: 'Workspace not found',
})
const now = new Date()
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
if (
!env.STRIPE_SECRET_KEY ||
!workspace.stripeId ||
(workspace.plan !== 'STARTER' && workspace.plan !== 'PRO')
) {
const now = new Date()
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
const totalChatsUsed = await prisma.result.count({
where: {
typebotId: { in: workspace.typebots.map((typebot) => typebot.id) },
hasStarted: true,
createdAt: {
gte: firstDayOfMonth,
},
},
})
const firstDayOfNextMonth = new Date(
firstDayOfMonth.getFullYear(),
firstDayOfMonth.getMonth() + 1,
1
)
return { totalChatsUsed, resetsAt: firstDayOfNextMonth }
}
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: '2022-11-15',
})
const subscriptions = await stripe.subscriptions.list({
customer: workspace.stripeId,
})
const currentSubscription = subscriptions.data
.filter((sub) => ['past_due', 'active'].includes(sub.status))
.sort((a, b) => a.created - b.created)
.shift()
if (!currentSubscription)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: `No subscription found on workspace: ${workspaceId}`,
})
const totalChatsUsed = await prisma.result.count({
where: {
typebotId: { in: workspace.typebots.map((typebot) => typebot.id) },
hasStarted: true,
createdAt: {
gte: firstDayOfMonth,
gte: new Date(currentSubscription.current_period_start * 1000),
},
},
})
return {
totalChatsUsed,
resetsAt: new Date(currentSubscription.current_period_end * 1000),
}
})

View File

@ -64,12 +64,12 @@ export const listInvoices = authenticatedProcedure
.filter(
(invoice) => isDefined(invoice.invoice_pdf) && isDefined(invoice.id)
)
.map((i) => ({
id: i.number as string,
url: i.invoice_pdf as string,
amount: i.subtotal,
currency: i.currency,
date: i.status_transitions.paid_at,
.map((invoice) => ({
id: invoice.number as string,
url: invoice.invoice_pdf as string,
amount: invoice.subtotal,
currency: invoice.currency,
date: invoice.status_transitions.paid_at,
})),
}
})

View File

@ -5,15 +5,10 @@ import { TRPCError } from '@trpc/server'
import { Plan } from '@typebot.io/prisma'
import { workspaceSchema } from '@typebot.io/schemas'
import Stripe from 'stripe'
import { isDefined } from '@typebot.io/lib'
import { z } from 'zod'
import { getChatsLimit } from '@typebot.io/lib/pricing'
import { chatPriceIds } from './getSubscription'
import { createCheckoutSessionUrl } from './createCheckoutSession'
import { isAdminWriteWorkspaceForbidden } from '@/features/workspace/helpers/isAdminWriteWorkspaceForbidden'
import { getUsage } from '@typebot.io/lib/api/getUsage'
import { env } from '@typebot.io/env'
import { priceIds } from '@typebot.io/lib/api/pricing'
export const updateSubscription = authenticatedProcedure
.meta({
@ -30,9 +25,7 @@ export const updateSubscription = authenticatedProcedure
returnUrl: z.string(),
workspaceId: z.string(),
plan: z.enum([Plan.STARTER, Plan.PRO]),
additionalChats: z.number(),
currency: z.enum(['usd', 'eur']),
isYearly: z.boolean(),
})
)
.output(
@ -43,14 +36,7 @@ export const updateSubscription = authenticatedProcedure
)
.mutation(
async ({
input: {
workspaceId,
plan,
additionalChats,
currency,
isYearly,
returnUrl,
},
input: { workspaceId, plan, currency, returnUrl },
ctx: { user },
}) => {
if (!env.STRIPE_SECRET_KEY)
@ -81,6 +67,7 @@ export const updateSubscription = authenticatedProcedure
code: 'NOT_FOUND',
message: 'Workspace not found',
})
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: '2022-11-15',
})
@ -91,39 +78,57 @@ export const updateSubscription = authenticatedProcedure
})
const subscription = data[0] as Stripe.Subscription | undefined
const currentPlanItemId = subscription?.items.data.find((item) =>
[env.STRIPE_STARTER_PRODUCT_ID, env.STRIPE_PRO_PRODUCT_ID].includes(
item.price.product.toString()
[env.STRIPE_STARTER_PRICE_ID, env.STRIPE_PRO_PRICE_ID].includes(
item.price.id
)
)?.id
const currentAdditionalChatsItemId = subscription?.items.data.find(
(item) => chatPriceIds.includes(item.price.id)
const currentUsageItemId = subscription?.items.data.find(
(item) =>
item.price.id === env.STRIPE_STARTER_CHATS_PRICE_ID ||
item.price.id === env.STRIPE_PRO_CHATS_PRICE_ID
)?.id
const frequency = isYearly ? 'yearly' : 'monthly'
const items = [
{
id: currentPlanItemId,
price: priceIds[plan].base[frequency],
price:
plan === Plan.STARTER
? env.STRIPE_STARTER_PRICE_ID
: env.STRIPE_PRO_PRICE_ID,
quantity: 1,
},
additionalChats === 0 && !currentAdditionalChatsItemId
? undefined
: {
id: currentAdditionalChatsItemId,
price: priceIds[plan].chats[frequency],
quantity: getChatsLimit({
plan,
additionalChatsIndex: additionalChats,
customChatsLimit: null,
}),
deleted: subscription ? additionalChats === 0 : undefined,
},
].filter(isDefined)
{
id: currentUsageItemId,
price:
plan === Plan.STARTER
? env.STRIPE_STARTER_CHATS_PRICE_ID
: env.STRIPE_PRO_CHATS_PRICE_ID,
},
]
if (subscription) {
if (plan === 'STARTER') {
const totalChatsUsed = await prisma.result.count({
where: {
typebot: { workspaceId },
hasStarted: true,
createdAt: {
gte: new Date(subscription.current_period_start * 1000),
},
},
})
if (totalChatsUsed >= 4000) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
"You have collected more than 4000 chats during this billing cycle. You can't downgrade to the Starter.",
})
}
}
await stripe.subscriptions.update(subscription.id, {
items,
proration_behavior: 'always_invoice',
proration_behavior:
plan === 'PRO' ? 'always_invoice' : 'create_prorations',
})
} else {
const checkoutUrl = await createCheckoutSessionUrl(stripe)({
@ -133,31 +138,16 @@ export const updateSubscription = authenticatedProcedure
currency,
plan,
returnUrl,
additionalChats,
isYearly,
})
return { checkoutUrl }
}
let isQuarantined = workspace.isQuarantined
if (isQuarantined) {
const newChatsLimit = getChatsLimit({
plan,
additionalChatsIndex: additionalChats,
customChatsLimit: null,
})
const { totalChatsUsed } = await getUsage(prisma)(workspaceId)
if (totalChatsUsed < newChatsLimit) isQuarantined = false
}
const updatedWorkspace = await prisma.workspace.update({
where: { id: workspaceId },
data: {
plan,
additionalChatsIndex: additionalChats,
isQuarantined,
isQuarantined: false,
},
})
@ -168,7 +158,6 @@ export const updateSubscription = authenticatedProcedure
userId: user.id,
data: {
plan,
additionalChatsIndex: additionalChats,
},
},
])

View File

@ -116,29 +116,25 @@ test('plan changes should work', async ({ page }) => {
await page.click('text=Plan Change Workspace')
await page.click('text=Settings & Members')
await page.click('text=Billing & Usage')
await page.click('button >> text="2,000"')
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 expect(page.locator('text="$39"')).toBeVisible()
await page.click('button >> text=Upgrade >> nth=0')
await page.getByLabel('Company name').fill('Company LLC')
await page.getByRole('button', { name: 'Go to checkout' }).click()
await page.waitForNavigation()
expect(page.url()).toContain('https://checkout.stripe.com')
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 expect(page.locator('text=$39 >> nth=0')).toBeVisible()
const stripeId = await addSubscriptionToWorkspace(
planChangeWorkspaceId,
[
{
price: env.STRIPE_STARTER_MONTHLY_PRICE_ID,
price: env.STRIPE_STARTER_PRICE_ID,
quantity: 1,
},
{
price: env.STRIPE_STARTER_CHATS_PRICE_ID,
},
],
{ plan: Plan.STARTER, additionalChatsIndex: 0 }
{ plan: Plan.STARTER }
)
// Update plan with additional quotas
@ -147,30 +143,9 @@ test('plan changes should work', async ({ page }) => {
await page.click('text=Billing & Usage')
await expect(page.locator('text="/ 2,000"')).toBeVisible()
await expect(page.getByText('/ 2,000')).toBeVisible()
await page.click('button >> text="2,000"')
await page.click('button >> text="3,500"')
await page.click('button >> text="2"')
await page.click('button >> text="4"')
await expect(page.locator('text="$73"')).toBeVisible()
await page.click('button >> text=Update')
await expect(
page.locator(
'text="Workspace STARTER plan successfully updated 🎉" >> nth=0'
)
).toBeVisible()
await page.click('text="Members"')
await page.click('text="Billing & Usage"')
await expect(page.locator('text="$73"')).toBeVisible()
await expect(page.locator('text="/ 3,500"')).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="25,000"')
await page.click('button >> text="10"')
await page.click('button >> text="15"')
await expect(page.locator('text="$247"')).toBeVisible()
await expect(page.locator('text="$89"')).toBeVisible()
await page.click('button >> text=Upgrade')
await expect(
page.locator('text="Workspace PRO plan successfully updated 🎉" >> nth=0')
@ -181,11 +156,12 @@ 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('$39.00')).toBeVisible({
timeout: 10000,
})
await expect(page.getByText('$50.00')).toBeVisible({
timeout: 10000,
})
await expect(page.getByText('(×25000)')).toBeVisible()
await expect(page.getByText('(×15)')).toBeVisible()
await expect(page.locator('text="Add payment method"')).toBeVisible()
await cancelSubscription(stripeId)
@ -212,9 +188,8 @@ test('should display invoices', async ({ page }) => {
await page.click('text=Billing & Usage')
await expect(page.locator('text="Invoices"')).toBeVisible()
await expect(page.locator('tr')).toHaveCount(4)
await expect(page.locator('text="$39.00"')).toBeVisible()
await expect(page.locator('text="$34.00"')).toBeVisible()
await expect(page.locator('text="$174.00"')).toBeVisible()
await expect(page.getByText('$39.00')).toBeVisible()
await expect(page.getByText('$50.00')).toBeVisible()
})
test('custom plans should work', async ({ page }) => {

View File

@ -1,9 +1,8 @@
import { Stack, HStack, Text, Switch, Tag } from '@chakra-ui/react'
import { Stack, HStack, Text } from '@chakra-ui/react'
import { Plan } from '@typebot.io/prisma'
import { TextLink } from '@/components/TextLink'
import { useToast } from '@/hooks/useToast'
import { trpc } from '@/lib/trpc'
import { guessIfUserIsEuropean } from '@typebot.io/lib/pricing'
import { Workspace } from '@typebot.io/schemas'
import { PreCheckoutModal, PreCheckoutModalProps } from './PreCheckoutModal'
import { useState } from 'react'
@ -13,6 +12,7 @@ import { StarterPlanPricingCard } from './StarterPlanPricingCard'
import { ProPlanPricingCard } from './ProPlanPricingCard'
import { useScopedI18n } from '@/locales'
import { StripeClimateLogo } from './StripeClimateLogo'
import { guessIfUserIsEuropean } from '@typebot.io/lib/billing/guessIfUserIsEuropean'
type Props = {
workspace: Workspace
@ -26,21 +26,12 @@ export const ChangePlanForm = ({ workspace, excludedPlans }: Props) => {
const { showToast } = useToast()
const [preCheckoutPlan, setPreCheckoutPlan] =
useState<PreCheckoutModalProps['selectedSubscription']>()
const [isYearly, setIsYearly] = useState(true)
const trpcContext = trpc.useContext()
const { data, refetch } = trpc.billing.getSubscription.useQuery(
{
workspaceId: workspace.id,
},
{
onSuccess: ({ subscription }) => {
if (isYearly === false) return
setIsYearly(subscription?.isYearly ?? true)
},
}
)
const { data, refetch } = trpc.billing.getSubscription.useQuery({
workspaceId: workspace.id,
})
const { mutate: updateSubscription, isLoading: isUpdatingSubscription } =
trpc.billing.updateSubscription.useMutation({
@ -65,23 +56,15 @@ export const ChangePlanForm = ({ workspace, excludedPlans }: Props) => {
},
})
const handlePayClick = async ({
plan,
selectedChatsLimitIndex,
}: {
plan: 'STARTER' | 'PRO'
selectedChatsLimitIndex: number
}) => {
if (!user || selectedChatsLimitIndex === undefined) return
const handlePayClick = async (plan: 'STARTER' | 'PRO') => {
if (!user) return
const newSubscription = {
plan,
workspaceId: workspace.id,
additionalChats: selectedChatsLimitIndex,
currency:
data?.subscription?.currency ??
(guessIfUserIsEuropean() ? 'eur' : 'usd'),
isYearly,
} as const
if (workspace.stripeId) {
updateSubscription({
@ -122,26 +105,11 @@ export const ChangePlanForm = ({ workspace, excludedPlans }: Props) => {
)}
{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">
{excludedPlans?.includes('STARTER') ? null : (
<StarterPlanPricingCard
workspace={workspace}
currentSubscription={{ isYearly: data.subscription?.isYearly }}
onPayClick={(props) =>
handlePayClick({ ...props, plan: Plan.STARTER })
}
isYearly={isYearly}
currentPlan={workspace.plan}
onPayClick={() => handlePayClick(Plan.STARTER)}
isLoading={isUpdatingSubscription}
currency={data.subscription?.currency}
/>
@ -149,12 +117,8 @@ export const ChangePlanForm = ({ workspace, excludedPlans }: Props) => {
{excludedPlans?.includes('PRO') ? null : (
<ProPlanPricingCard
workspace={workspace}
currentSubscription={{ isYearly: data.subscription?.isYearly }}
onPayClick={(props) =>
handlePayClick({ ...props, plan: Plan.PRO })
}
isYearly={isYearly}
currentPlan={workspace.plan}
onPayClick={() => handlePayClick(Plan.PRO)}
isLoading={isUpdatingSubscription}
currency={data.subscription?.currency}
/>

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

@ -12,8 +12,8 @@ type FeaturesListProps = { features: (string | JSX.Element)[] } & ListProps
export const FeaturesList = ({ features, ...props }: FeaturesListProps) => (
<UnorderedList listStyleType="none" spacing={2} {...props}>
{features.map((feat, idx) => (
<Flex as={ListItem} key={idx} alignItems="center">
<ListIcon as={CheckIcon} />
<Flex as={ListItem} key={idx}>
<ListIcon as={CheckIcon} mt="1.5" />
{feat}
</Flex>
))}

View File

@ -25,9 +25,7 @@ export type PreCheckoutModalProps = {
| {
plan: 'STARTER' | 'PRO'
workspaceId: string
additionalChats: number
currency: 'eur' | 'usd'
isYearly: boolean
}
| undefined
existingCompany?: string

View File

@ -3,226 +3,152 @@ import {
Heading,
chakra,
HStack,
Menu,
MenuButton,
Button,
MenuList,
MenuItem,
Text,
Tooltip,
Flex,
Tag,
useColorModeValue,
useDisclosure,
} from '@chakra-ui/react'
import { ChevronLeftIcon } from '@/components/icons'
import { Plan } from '@typebot.io/prisma'
import { useEffect, useState } from 'react'
import { isDefined, parseNumberWithCommas } from '@typebot.io/lib'
import {
chatsLimit,
computePrice,
formatPrice,
getChatsLimit,
} from '@typebot.io/lib/pricing'
import { FeaturesList } from './FeaturesList'
import { MoreInfoTooltip } from '@/components/MoreInfoTooltip'
import { useI18n, useScopedI18n } from '@/locales'
import { Workspace } from '@typebot.io/schemas'
import { formatPrice } from '@typebot.io/lib/billing/formatPrice'
import { ChatsProTiersModal } from './ChatsProTiersModal'
import { prices } from '@typebot.io/lib/billing/constants'
type Props = {
workspace: Pick<
Workspace,
| 'additionalChatsIndex'
| 'plan'
| 'customChatsLimit'
| 'customStorageLimit'
| 'stripeId'
>
currentSubscription: {
isYearly?: boolean
}
currentPlan: Plan
currency?: 'usd' | 'eur'
isLoading: boolean
isYearly: boolean
onPayClick: (props: { selectedChatsLimitIndex: number }) => void
onPayClick: () => void
}
export const ProPlanPricingCard = ({
workspace,
currentSubscription,
currentPlan,
currency,
isLoading,
isYearly,
onPayClick,
}: Props) => {
const t = useI18n()
const scopedT = useScopedI18n('billing.pricingCard')
const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] =
useState<number>()
useEffect(() => {
if (isDefined(selectedChatsLimitIndex)) return
if (workspace.plan !== Plan.PRO) {
setSelectedChatsLimitIndex(0)
return
}
setSelectedChatsLimitIndex(workspace.additionalChatsIndex ?? 0)
}, [selectedChatsLimitIndex, workspace.additionalChatsIndex, workspace.plan])
const workspaceChatsLimit = workspace ? getChatsLimit(workspace) : undefined
const isCurrentPlan =
chatsLimit[Plan.PRO].graduatedPrice[selectedChatsLimitIndex ?? 0]
.totalIncluded === workspaceChatsLimit &&
isYearly === currentSubscription?.isYearly
const { isOpen, onOpen, onClose } = useDisclosure()
const getButtonLabel = () => {
if (selectedChatsLimitIndex === undefined) return ''
if (workspace?.plan === Plan.PRO) {
if (isCurrentPlan) return scopedT('upgradeButton.current')
if (selectedChatsLimitIndex !== workspace.additionalChatsIndex)
return t('update')
}
if (currentPlan === Plan.PRO) return scopedT('upgradeButton.current')
return t('upgrade')
}
const handlePayClick = async () => {
if (selectedChatsLimitIndex === undefined) return
onPayClick({
selectedChatsLimitIndex,
})
}
const price =
computePrice(
Plan.PRO,
selectedChatsLimitIndex ?? 0,
isYearly ? 'yearly' : 'monthly'
) ?? NaN
return (
<Flex
p="6"
pos="relative"
h="full"
flexDir="column"
flex="1"
flexShrink={0}
borderWidth="1px"
borderColor={useColorModeValue('blue.500', 'blue.300')}
rounded="lg"
>
<Flex justifyContent="center">
<Tag
pos="absolute"
top="-10px"
colorScheme="blue"
bg={useColorModeValue('blue.500', 'blue.400')}
variant="solid"
fontWeight="semibold"
style={{ marginTop: 0 }}
>
{scopedT('pro.mostPopularLabel')}
</Tag>
</Flex>
<Stack justifyContent="space-between" h="full">
<Stack spacing="4" mt={2}>
<Heading fontSize="2xl">
{scopedT('heading', {
plan: (
<chakra.span color={useColorModeValue('blue.400', 'blue.300')}>
Pro
</chakra.span>
),
})}
</Heading>
<Text>{scopedT('pro.description')}</Text>
</Stack>
<Stack spacing="4">
<Heading>
{formatPrice(price, currency)}
<chakra.span fontSize="md">{scopedT('perMonth')}</chakra.span>
</Heading>
<Text fontWeight="bold">
<Tooltip
label={
<FeaturesList
features={[
scopedT('starter.brandingRemoved'),
scopedT('starter.fileUploadBlock'),
scopedT('starter.createFolders'),
]}
spacing="0"
/>
}
hasArrow
placement="top"
>
<chakra.span textDecoration="underline" cursor="pointer">
{scopedT('pro.everythingFromStarter')}
</chakra.span>
</Tooltip>
{scopedT('plus')}
</Text>
<FeaturesList
features={[
scopedT('pro.includedSeats'),
<HStack key="test">
<Text>
<Menu>
<MenuButton
as={Button}
rightIcon={<ChevronLeftIcon transform="rotate(-90deg)" />}
size="sm"
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>{' '}
{scopedT('chatsPerMonth')}
</Text>
<MoreInfoTooltip>{scopedT('chatsTooltip')}</MoreInfoTooltip>
</HStack>,
scopedT('pro.whatsAppIntegration'),
scopedT('pro.customDomains'),
scopedT('pro.analytics'),
]}
/>
<Stack spacing={3}>
{isYearly && workspace.stripeId && !isCurrentPlan && (
<Heading mt="0" fontSize="md">
You pay {formatPrice(price * 12, currency)} / year
<>
<ChatsProTiersModal isOpen={isOpen} onClose={onClose} />{' '}
<Flex
p="6"
pos="relative"
h="full"
flexDir="column"
flex="1"
flexShrink={0}
borderWidth="1px"
borderColor={useColorModeValue('blue.500', 'blue.300')}
rounded="lg"
>
<Flex justifyContent="center">
<Tag
pos="absolute"
top="-10px"
colorScheme="blue"
bg={useColorModeValue('blue.500', 'blue.400')}
variant="solid"
fontWeight="semibold"
style={{ marginTop: 0 }}
>
{scopedT('pro.mostPopularLabel')}
</Tag>
</Flex>
<Stack justifyContent="space-between" h="full">
<Stack spacing="4" mt={2}>
<Heading fontSize="2xl">
{scopedT('heading', {
plan: (
<chakra.span
color={useColorModeValue('blue.400', 'blue.300')}
>
Pro
</chakra.span>
),
})}
</Heading>
<Text>{scopedT('pro.description')}</Text>
</Stack>
<Stack spacing="8">
<Stack spacing="4">
<Heading>
{formatPrice(prices.PRO, { currency })}
<chakra.span fontSize="md">{scopedT('perMonth')}</chakra.span>
</Heading>
)}
<Text fontWeight="bold">
<Tooltip
label={
<FeaturesList
features={[
scopedT('starter.brandingRemoved'),
scopedT('starter.fileUploadBlock'),
scopedT('starter.createFolders'),
]}
spacing="0"
/>
}
hasArrow
placement="top"
>
<chakra.span textDecoration="underline" cursor="pointer">
{scopedT('pro.everythingFromStarter')}
</chakra.span>
</Tooltip>
{scopedT('plus')}
</Text>
<FeaturesList
features={[
scopedT('pro.includedSeats'),
<Stack key="starter-chats" spacing={1}>
<HStack key="test">
<Text>10,000 {scopedT('chatsPerMonth')}</Text>
<MoreInfoTooltip>
{scopedT('chatsTooltip')}
</MoreInfoTooltip>
</HStack>
<Text
fontSize="sm"
color={useColorModeValue('gray.500', 'gray.400')}
>
Extra chats:{' '}
<Button size="xs" variant="outline" onClick={onOpen}>
See tiers
</Button>
</Text>
</Stack>,
scopedT('pro.whatsAppIntegration'),
scopedT('pro.customDomains'),
scopedT('pro.analytics'),
]}
/>
</Stack>
<Button
colorScheme="blue"
variant="outline"
onClick={handlePayClick}
onClick={onPayClick}
isLoading={isLoading}
isDisabled={isCurrentPlan}
isDisabled={currentPlan === Plan.PRO}
>
{getButtonLabel()}
</Button>
</Stack>
</Stack>
</Stack>
</Flex>
</Flex>
</>
)
}

View File

@ -3,174 +3,96 @@ import {
Heading,
chakra,
HStack,
Menu,
MenuButton,
Button,
MenuList,
MenuItem,
Text,
useColorModeValue,
} from '@chakra-ui/react'
import { ChevronLeftIcon } from '@/components/icons'
import { Plan } from '@typebot.io/prisma'
import { useEffect, useState } from 'react'
import { isDefined, parseNumberWithCommas } from '@typebot.io/lib'
import {
chatsLimit,
computePrice,
formatPrice,
getChatsLimit,
} from '@typebot.io/lib/pricing'
import { FeaturesList } from './FeaturesList'
import { MoreInfoTooltip } from '@/components/MoreInfoTooltip'
import { useI18n, useScopedI18n } from '@/locales'
import { Workspace } from '@typebot.io/schemas'
import { formatPrice } from '@typebot.io/lib/billing/formatPrice'
import { prices } from '@typebot.io/lib/billing/constants'
type Props = {
workspace: Pick<
Workspace,
| 'additionalChatsIndex'
| 'plan'
| 'customChatsLimit'
| 'customStorageLimit'
| 'stripeId'
>
currentSubscription: {
isYearly?: boolean
}
currentPlan: Plan
currency?: 'eur' | 'usd'
isLoading?: boolean
isYearly: boolean
onPayClick: (props: { selectedChatsLimitIndex: number }) => void
onPayClick: () => void
}
export const StarterPlanPricingCard = ({
workspace,
currentSubscription,
currentPlan,
isLoading,
currency,
isYearly,
onPayClick,
}: Props) => {
const t = useI18n()
const scopedT = useScopedI18n('billing.pricingCard')
const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] =
useState<number>()
useEffect(() => {
if (isDefined(selectedChatsLimitIndex)) return
if (workspace.plan !== Plan.STARTER) {
setSelectedChatsLimitIndex(0)
return
}
setSelectedChatsLimitIndex(workspace.additionalChatsIndex ?? 0)
}, [selectedChatsLimitIndex, workspace.additionalChatsIndex, workspace.plan])
const workspaceChatsLimit = workspace ? getChatsLimit(workspace) : undefined
const isCurrentPlan =
chatsLimit[Plan.STARTER].graduatedPrice[selectedChatsLimitIndex ?? 0]
.totalIncluded === workspaceChatsLimit &&
isYearly === currentSubscription?.isYearly
const getButtonLabel = () => {
if (selectedChatsLimitIndex === undefined) return ''
if (workspace?.plan === Plan.PRO) return t('downgrade')
if (workspace?.plan === Plan.STARTER) {
if (isCurrentPlan) return scopedT('upgradeButton.current')
if (
selectedChatsLimitIndex !== workspace.additionalChatsIndex ||
isYearly !== currentSubscription?.isYearly
)
return t('update')
}
if (currentPlan === Plan.PRO) return t('downgrade')
if (currentPlan === Plan.STARTER) return scopedT('upgradeButton.current')
return t('upgrade')
}
const handlePayClick = async () => {
if (selectedChatsLimitIndex === undefined) return
onPayClick({
selectedChatsLimitIndex,
})
}
const price =
computePrice(
Plan.STARTER,
selectedChatsLimitIndex ?? 0,
isYearly ? 'yearly' : 'monthly'
) ?? NaN
return (
<Stack spacing={6} p="6" rounded="lg" borderWidth="1px" flex="1" h="full">
<Stack
spacing={6}
p="6"
rounded="lg"
borderWidth="1px"
flex="1"
h="full"
justifyContent="space-between"
pt="8"
>
<Stack spacing="4">
<Heading fontSize="2xl">
{scopedT('heading', {
plan: <chakra.span color="orange.400">Starter</chakra.span>,
})}
</Heading>
<Text>{scopedT('starter.description')}</Text>
<Heading>
{formatPrice(price, currency)}
<chakra.span fontSize="md">{scopedT('perMonth')}</chakra.span>
</Heading>
<Stack>
<Stack spacing="4">
<Heading fontSize="2xl">
{scopedT('heading', {
plan: <chakra.span color="orange.400">Starter</chakra.span>,
})}
</Heading>
<Text>{scopedT('starter.description')}</Text>
</Stack>
<Heading>
{formatPrice(prices.STARTER, { currency })}
<chakra.span fontSize="md">{scopedT('perMonth')}</chakra.span>
</Heading>
</Stack>
<FeaturesList
features={[
scopedT('starter.includedSeats'),
<HStack key="test">
<Text>
<Menu>
<MenuButton
as={Button}
rightIcon={<ChevronLeftIcon transform="rotate(-90deg)" />}
size="sm"
isLoading={selectedChatsLimitIndex === undefined}
>
{selectedChatsLimitIndex !== undefined
? parseNumberWithCommas(
chatsLimit.STARTER.graduatedPrice[
selectedChatsLimitIndex
].totalIncluded
)
: undefined}
</MenuButton>
<MenuList>
{chatsLimit.STARTER.graduatedPrice.map((price, index) => (
<MenuItem
key={index}
onClick={() => setSelectedChatsLimitIndex(index)}
>
{parseNumberWithCommas(price.totalIncluded)}
</MenuItem>
))}
</MenuList>
</Menu>{' '}
{scopedT('chatsPerMonth')}
<Stack key="starter-chats" spacing={0}>
<HStack>
<Text>2,000 {scopedT('chatsPerMonth')}</Text>
<MoreInfoTooltip>{scopedT('chatsTooltip')}</MoreInfoTooltip>
</HStack>
<Text
fontSize="sm"
color={useColorModeValue('gray.500', 'gray.400')}
>
Extra chats: $10 per 500
</Text>
<MoreInfoTooltip>{scopedT('chatsTooltip')}</MoreInfoTooltip>
</HStack>,
</Stack>,
scopedT('starter.brandingRemoved'),
scopedT('starter.fileUploadBlock'),
scopedT('starter.createFolders'),
]}
/>
</Stack>
<Stack>
{isYearly && workspace.stripeId && !isCurrentPlan && (
<Heading mt="0" fontSize="md">
You pay: {formatPrice(price * 12, currency)} / year
</Heading>
)}
<Button
colorScheme="orange"
variant="outline"
onClick={handlePayClick}
isLoading={isLoading}
isDisabled={isCurrentPlan}
>
{getButtonLabel()}
</Button>
</Stack>
<Button
colorScheme="orange"
variant="outline"
onClick={onPayClick}
isLoading={isLoading}
isDisabled={currentPlan === Plan.STARTER}
>
{getButtonLabel()}
</Button>
</Stack>
)
}

View File

@ -12,9 +12,9 @@ import { AlertIcon } from '@/components/icons'
import { Workspace } from '@typebot.io/prisma'
import React from 'react'
import { parseNumberWithCommas } from '@typebot.io/lib'
import { getChatsLimit } from '@typebot.io/lib/pricing'
import { defaultQueryOptions, trpc } from '@/lib/trpc'
import { useScopedI18n } from '@/locales'
import { getChatsLimit } from '@typebot.io/lib/billing/getChatsLimit'
type Props = {
workspace: Workspace
@ -65,7 +65,7 @@ export const UsageProgressBars = ({ workspace }: Props) => {
</Tooltip>
)}
<Text fontSize="sm" fontStyle="italic" color="gray.500">
{scopedT('chats.resetInfo')}
(Resets on {data?.resetsAt.toLocaleDateString()})
</Text>
</HStack>
@ -90,9 +90,8 @@ export const UsageProgressBars = ({ workspace }: Props) => {
h="5px"
value={chatsPercentage}
rounded="full"
hasStripe
isIndeterminate={isLoading}
colorScheme={totalChatsUsed >= workspaceChatsLimit ? 'red' : 'blue'}
colorScheme={'blue'}
/>
</Stack>
</Stack>

View File

@ -1,29 +0,0 @@
import { getChatsLimit } from '@typebot.io/lib/pricing'
import { priceIds } from '@typebot.io/lib/api/pricing'
export const parseSubscriptionItems = (
plan: 'STARTER' | 'PRO',
additionalChats: number,
isYearly: boolean
) => {
const frequency = isYearly ? 'yearly' : 'monthly'
return [
{
price: priceIds[plan].base[frequency],
quantity: 1,
},
].concat(
additionalChats > 0
? [
{
price: priceIds[plan].chats[frequency],
quantity: getChatsLimit({
plan,
additionalChatsIndex: additionalChats,
customChatsLimit: null,
}),
},
]
: []
)
}

View File

@ -10,12 +10,12 @@ import { Stack, VStack, Spinner, Text } from '@chakra-ui/react'
import { Plan } from '@typebot.io/prisma'
import { useRouter } from 'next/router'
import { useState, useEffect } from 'react'
import { guessIfUserIsEuropean } from '@typebot.io/lib/pricing'
import { DashboardHeader } from './DashboardHeader'
import { FolderContent } from '@/features/folders/components/FolderContent'
import { TypebotDndProvider } from '@/features/folders/TypebotDndProvider'
import { ParentModalProvider } from '@/features/graph/providers/ParentModalProvider'
import { trpc } from '@/lib/trpc'
import { guessIfUserIsEuropean } from '@typebot.io/lib/billing/guessIfUserIsEuropean'
export const DashboardPage = () => {
const scopedT = useScopedI18n('dashboard')
@ -33,13 +33,11 @@ export const DashboardPage = () => {
})
useEffect(() => {
const { subscribePlan, chats, isYearly, claimCustomPlan } =
router.query as {
subscribePlan: Plan | undefined
chats: string | undefined
isYearly: string | undefined
claimCustomPlan: string | undefined
}
const { subscribePlan, claimCustomPlan } = router.query as {
subscribePlan: Plan | undefined
chats: string | undefined
claimCustomPlan: string | undefined
}
if (claimCustomPlan && user?.email && workspace) {
setIsLoading(true)
createCustomCheckoutSession({
@ -53,9 +51,7 @@ export const DashboardPage = () => {
setPreCheckoutPlan({
plan: subscribePlan as 'PRO' | 'STARTER',
workspaceId: workspace.id,
additionalChats: chats ? parseInt(chats) : 0,
currency: guessIfUserIsEuropean() ? 'eur' : 'usd',
isYearly: isYearly === 'false' ? false : true,
})
}
}, [createCustomCheckoutSession, router.query, user, workspace])

View File

@ -18,7 +18,6 @@ import { useMemo } from 'react'
import { useStats } from '../hooks/useStats'
import { ResultsProvider } from '../ResultsProvider'
import { ResultsTableContainer } from './ResultsTableContainer'
import { UsageAlertBanners } from './UsageAlertBanners'
export const ResultsPage = () => {
const router = useRouter()
@ -56,7 +55,6 @@ export const ResultsPage = () => {
}
/>
<TypebotHeader />
{workspace && <UsageAlertBanners workspace={workspace} />}
<Flex
h="full"
w="full"

View File

@ -1,50 +0,0 @@
import { UnlockPlanAlertInfo } from '@/components/UnlockPlanAlertInfo'
import { trpc } from '@/lib/trpc'
import { Flex } from '@chakra-ui/react'
import { Workspace } from '@typebot.io/schemas'
import { useMemo } from 'react'
import { getChatsLimit } from '@typebot.io/lib/pricing'
const ALERT_CHATS_PERCENT_THRESHOLD = 80
type Props = {
workspace: Workspace
}
export const UsageAlertBanners = ({ workspace }: Props) => {
const { data: usageData } = trpc.billing.getUsage.useQuery({
workspaceId: workspace?.id,
})
const chatsLimitPercentage = useMemo(() => {
if (!usageData?.totalChatsUsed || !workspace?.plan) return 0
return Math.round(
(usageData.totalChatsUsed /
getChatsLimit({
additionalChatsIndex: workspace.additionalChatsIndex,
plan: workspace.plan,
customChatsLimit: workspace.customChatsLimit,
})) *
100
)
}, [
usageData?.totalChatsUsed,
workspace?.additionalChatsIndex,
workspace?.customChatsLimit,
workspace?.plan,
])
return (
<>
{chatsLimitPercentage > ALERT_CHATS_PERCENT_THRESHOLD && (
<Flex p="4">
<UnlockPlanAlertInfo status="warning" buttonLabel="Upgrade">
Your workspace collected <strong>{chatsLimitPercentage}%</strong> of
your total chats limit this month. Upgrade your plan to continue
chatting with your customers beyond this limit.
</UnlockPlanAlertInfo>
</Flex>
)}
</>
)
}

View File

@ -8,7 +8,6 @@ import {
import { UnlockPlanAlertInfo } from '@/components/UnlockPlanAlertInfo'
import { WorkspaceInvitation, WorkspaceRole } from '@typebot.io/prisma'
import React from 'react'
import { getSeatsLimit, isSeatsLimitReached } from '@typebot.io/lib/pricing'
import { AddMemberForm } from './AddMemberForm'
import { MemberItem } from './MemberItem'
import { isDefined } from '@typebot.io/lib'
@ -21,6 +20,7 @@ import { updateMemberQuery } from '../queries/updateMemberQuery'
import { Member } from '../types'
import { useWorkspace } from '../WorkspaceProvider'
import { useScopedI18n } from '@/locales'
import { getSeatsLimit } from '@typebot.io/lib/billing/getSeatsLimit'
export const MembersList = () => {
const scopedT = useScopedI18n('workspace.membersList')
@ -92,13 +92,9 @@ export const MembersList = () => {
const seatsLimit = workspace ? getSeatsLimit(workspace) : undefined
const canInviteNewMember =
workspace &&
!isSeatsLimitReached({
plan: workspace?.plan,
customSeatsLimit: workspace?.customSeatsLimit,
existingMembersAndInvitationsCount: currentMembersCount,
})
const canInviteNewMember = workspace
? currentMembersCount < (seatsLimit as number)
: false
return (
<Stack w="full" spacing={3}>

View File

@ -46,23 +46,22 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
const metadata = session.metadata as unknown as
| {
plan: 'STARTER' | 'PRO'
additionalChats: string
workspaceId: string
userId: string
}
| { claimableCustomPlanId: string; userId: string }
if ('plan' in metadata) {
const { workspaceId, plan, additionalChats } = metadata
if (!workspaceId || !plan || !additionalChats)
const { workspaceId, plan } = metadata
if (!workspaceId || !plan)
return res
.status(500)
.send({ message: `Couldn't retrieve valid metadata` })
const workspace = await prisma.workspace.update({
where: { id: workspaceId },
data: {
plan,
stripeId: session.customer as string,
additionalChatsIndex: parseInt(additionalChats),
isQuarantined: false,
},
include: {
@ -84,7 +83,6 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
userId: user.id,
data: {
plan,
additionalChatsIndex: parseInt(additionalChats),
},
},
])
@ -119,7 +117,6 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
userId,
data: {
plan: Plan.CUSTOM,
additionalChatsIndex: 0,
},
},
])
@ -148,7 +145,6 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
},
data: {
plan: Plan.FREE,
additionalChatsIndex: 0,
customChatsLimit: null,
customStorageLimit: null,
customSeatsLimit: null,
@ -172,7 +168,6 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
userId: user.id,
data: {
plan: Plan.FREE,
additionalChatsIndex: 0,
},
},
])

View File

@ -8,7 +8,7 @@ import {
} from '@typebot.io/lib/api'
import { getAuthenticatedUser } from '@/features/auth/helpers/getAuthenticatedUser'
import { sendWorkspaceMemberInvitationEmail } from '@typebot.io/emails'
import { isSeatsLimitReached } from '@typebot.io/lib/pricing'
import { getSeatsLimit } from '@typebot.io/lib/billing/getSeatsLimit'
import { env } from '@typebot.io/env'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
@ -37,11 +37,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
}),
])
if (
isSeatsLimitReached({
existingMembersAndInvitationsCount:
existingMembersCount + existingInvitationsCount,
...workspace,
})
getSeatsLimit(workspace) <=
existingMembersCount + existingInvitationsCount
)
return res.status(400).send('Seats limit reached')
if (existingUser) {

View File

@ -18,7 +18,7 @@ const stripe = new Stripe(env.STRIPE_SECRET_KEY ?? '', {
export const addSubscriptionToWorkspace = async (
workspaceId: string,
items: Stripe.SubscriptionCreateParams.Item[],
metadata: Pick<Workspace, 'additionalChatsIndex' | 'plan'>
metadata: Pick<Workspace, 'plan'>
) => {
const { id: stripeId } = await stripe.customers.create({
email: 'test-user@gmail.com',

View File

@ -228,25 +228,15 @@ The related environment variables are listed here but you are probably not inter
<details><summary><h4>Stripe</h4></summary>
<p>
| Parameter | Default | Description |
| --------------------------------------- | ------- | ------------------------------------------- |
| NEXT_PUBLIC_STRIPE_PUBLIC_KEY | | Stripe public key |
| STRIPE_SECRET_KEY | | Stripe secret key |
| STRIPE_STARTER_PRODUCT_ID | | Starter plan product ID |
| STRIPE_STARTER_MONTHLY_PRICE_ID | | Starter monthly plan price id |
| STRIPE_STARTER_YEARLY_PRICE_ID | | Starter yearly plan price id |
| STRIPE_PRO_PRODUCT_ID | | Pro plan product ID |
| STRIPE_PRO_MONTHLY_PRICE_ID | | Pro monthly plan price id |
| STRIPE_PRO_YEARLY_PRICE_ID | | Pro yearly plan price id |
| STRIPE_STARTER_CHATS_MONTHLY_PRICE_ID | | Starter Additional chats monthly price id |
| STRIPE_STARTER_CHATS_YEARLY_PRICE_ID | | Starter Additional chats yearly price id |
| STRIPE_PRO_CHATS_MONTHLY_PRICE_ID | | Pro Additional chats monthly price id |
| STRIPE_PRO_CHATS_YEARLY_PRICE_ID | | Pro Additional chats yearly price id |
| STRIPE_STARTER_STORAGE_MONTHLY_PRICE_ID | | Starter Additional storage monthly price id |
| STRIPE_STARTER_STORAGE_YEARLY_PRICE_ID | | Starter Additional storage yearly price id |
| STRIPE_PRO_STORAGE_MONTHLY_PRICE_ID | | Pro Additional storage monthly price id |
| STRIPE_PRO_STORAGE_YEARLY_PRICE_ID | | Pro Additional storage yearly price id |
| STRIPE_WEBHOOK_SECRET | | Stripe Webhook secret |
| Parameter | Default | Description |
| ----------------------------- | ------- | ----------------------------------------- |
| NEXT_PUBLIC_STRIPE_PUBLIC_KEY | | Stripe public key |
| STRIPE_SECRET_KEY | | Stripe secret key |
| STRIPE_STARTER_PRICE_ID | | Starter plan price id |
| STRIPE_PRO_PRICE_ID | | Pro monthly plan price id |
| STRIPE_STARTER_CHATS_PRICE_ID | | Starter Additional chats monthly price id |
| STRIPE_PRO_CHATS_PRICE_ID | | Pro Additional chats monthly price id |
| STRIPE_WEBHOOK_SECRET | | Stripe Webhook secret |
</p></details>

View File

@ -229,14 +229,10 @@
"CUSTOM",
"UNLIMITED"
]
},
"additionalChatsIndex": {
"type": "number"
}
},
"required": [
"plan",
"additionalChatsIndex"
"plan"
],
"additionalProperties": false
}
@ -643,6 +639,14 @@
"youtube",
"vimeo"
]
},
"height": {
"anyOf": [
{
"type": "number"
},
{}
]
}
},
"additionalProperties": false
@ -5032,6 +5036,14 @@
"youtube",
"vimeo"
]
},
"height": {
"anyOf": [
{
"type": "number"
},
{}
]
}
},
"additionalProperties": false
@ -9062,6 +9074,14 @@
"youtube",
"vimeo"
]
},
"height": {
"anyOf": [
{
"type": "number"
},
{}
]
}
},
"additionalProperties": false
@ -13232,6 +13252,14 @@
"youtube",
"vimeo"
]
},
"height": {
"anyOf": [
{
"type": "number"
},
{}
]
}
},
"additionalProperties": false
@ -17282,6 +17310,14 @@
"youtube",
"vimeo"
]
},
"height": {
"anyOf": [
{
"type": "number"
},
{}
]
}
},
"additionalProperties": false
@ -21387,6 +21423,14 @@
"youtube",
"vimeo"
]
},
"height": {
"anyOf": [
{
"type": "number"
},
{}
]
}
},
"additionalProperties": false
@ -25555,6 +25599,14 @@
"youtube",
"vimeo"
]
},
"height": {
"anyOf": [
{
"type": "number"
},
{}
]
}
},
"additionalProperties": false
@ -30419,9 +30471,6 @@
"returnUrl": {
"type": "string"
},
"additionalChats": {
"type": "number"
},
"vat": {
"type": "object",
"properties": {
@ -30437,9 +30486,6 @@
"value"
],
"additionalProperties": false
},
"isYearly": {
"type": "boolean"
}
},
"required": [
@ -30448,9 +30494,7 @@
"workspaceId",
"currency",
"plan",
"returnUrl",
"additionalChats",
"isYearly"
"returnUrl"
],
"additionalProperties": false
}
@ -30516,27 +30560,19 @@
"PRO"
]
},
"additionalChats": {
"type": "number"
},
"currency": {
"type": "string",
"enum": [
"usd",
"eur"
]
},
"isYearly": {
"type": "boolean"
}
},
"required": [
"returnUrl",
"workspaceId",
"plan",
"additionalChats",
"currency",
"isYearly"
"currency"
],
"additionalProperties": false
}
@ -30706,8 +30742,23 @@
{
"type": "object",
"properties": {
"isYearly": {
"type": "boolean"
"currentBillingPeriod": {
"type": "object",
"properties": {
"start": {
"type": "string",
"format": "date-time"
},
"end": {
"type": "string",
"format": "date-time"
}
},
"required": [
"start",
"end"
],
"additionalProperties": false
},
"currency": {
"type": "string",
@ -30729,7 +30780,7 @@
}
},
"required": [
"isYearly",
"currentBillingPeriod",
"currency",
"status"
],
@ -30790,10 +30841,15 @@
"properties": {
"totalChatsUsed": {
"type": "number"
},
"resetsAt": {
"type": "string",
"format": "date-time"
}
},
"required": [
"totalChatsUsed"
"totalChatsUsed",
"resetsAt"
],
"additionalProperties": false
}

View File

@ -238,6 +238,14 @@
"youtube",
"vimeo"
]
},
"height": {
"anyOf": [
{
"type": "number"
},
{}
]
}
},
"additionalProperties": false
@ -4196,6 +4204,14 @@
"youtube",
"vimeo"
]
},
"height": {
"anyOf": [
{
"type": "number"
},
{}
]
}
},
"additionalProperties": false
@ -5674,9 +5690,6 @@
},
"additionalProperties": false
}
},
"displayStream": {
"type": "boolean"
}
},
"required": [

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>

View File

@ -23,7 +23,6 @@ export const findPublicTypebot = ({ publicId }: Props) =>
select: {
id: true,
plan: true,
additionalChatsIndex: true,
customChatsLimit: true,
isQuarantined: true,
isSuspended: true,

View File

@ -1,21 +1,15 @@
import React, { ComponentProps } from 'react'
import {
Mjml,
MjmlBody,
MjmlSection,
MjmlColumn,
MjmlSpacer,
} from '@faire/mjml-react'
import { ComponentProps } from 'react'
import { Mjml, MjmlBody, MjmlSection, MjmlColumn } from '@faire/mjml-react'
import { render } from '@faire/mjml-react/utils/render'
import { Button, Head, HeroImage, Text } from '../components'
import { Head, HeroImage, Text } from '../components'
import { parseNumberWithCommas } from '@typebot.io/lib'
import { SendMailOptions } from 'nodemailer'
import { sendEmail } from '../sendEmail'
type AlmostReachedChatsLimitEmailProps = {
workspaceName: string
usagePercent: number
chatsLimit: number
url: string
}
const now = new Date()
@ -27,9 +21,9 @@ const readableResetDate = firstDayOfNextMonth
.join(' ')
export const AlmostReachedChatsLimitEmail = ({
workspaceName,
usagePercent,
chatsLimit,
url,
}: AlmostReachedChatsLimitEmailProps) => {
const readableChatsLimit = parseNumberWithCommas(chatsLimit)
@ -46,18 +40,22 @@ export const AlmostReachedChatsLimitEmail = ({
<MjmlColumn>
<Text>Your bots are chatting a lot. That&apos;s amazing. 💙</Text>
<Text>
This means you&apos;ve almost reached your monthly chats limit.
You currently reached {usagePercent}% of {readableChatsLimit}{' '}
Your workspace <strong>{workspaceName}</strong> has used{' '}
{usagePercent}% of the included chats this month. Once you hit{' '}
{readableChatsLimit} chats, you will pay as you go for additional
chats.
</Text>
<Text>This limit will be reset on {readableResetDate}.</Text>
<Text fontWeight="800">
Upon this limit your bots will still continue to chat, but we ask
you kindly to upgrade your monthly chats limit.
<Text>
Your progress can be monitored on your workspace dashboard
settings. Check out the{' '}
<a href="https://typebot.io/pricing">pricing page</a> for
information about the pay as you go tiers.
</Text>
<Text>
As a reminder, your billing cycle ends on {readableResetDate}. If
you&apos;d like to learn more about the Enterprise plan for an
annual commitment, reach out to .
</Text>
<MjmlSpacer height="24px" />
<Button link={url}>Upgrade workspace</Button>
</MjmlColumn>
</MjmlSection>
</MjmlBody>

18
packages/env/env.ts vendored
View File

@ -140,20 +140,10 @@ const stripeEnv = {
server: {
STRIPE_SECRET_KEY: z.string().min(1).optional(),
STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(),
STRIPE_STARTER_PRODUCT_ID: z.string().min(1).optional(),
STRIPE_STARTER_MONTHLY_PRICE_ID: z.string().min(1).optional(),
STRIPE_STARTER_YEARLY_PRICE_ID: z.string().min(1).optional(),
STRIPE_STARTER_CHATS_MONTHLY_PRICE_ID: z.string().min(1).optional(),
STRIPE_STARTER_CHATS_YEARLY_PRICE_ID: z.string().min(1).optional(),
STRIPE_STARTER_STORAGE_MONTHLY_PRICE_ID: z.string().min(1).optional(),
STRIPE_STARTER_STORAGE_YEARLY_PRICE_ID: z.string().min(1).optional(),
STRIPE_PRO_PRODUCT_ID: z.string().min(1).optional(),
STRIPE_PRO_MONTHLY_PRICE_ID: z.string().min(1).optional(),
STRIPE_PRO_YEARLY_PRICE_ID: z.string().min(1).optional(),
STRIPE_PRO_CHATS_MONTHLY_PRICE_ID: z.string().min(1).optional(),
STRIPE_PRO_CHATS_YEARLY_PRICE_ID: z.string().min(1).optional(),
STRIPE_PRO_STORAGE_MONTHLY_PRICE_ID: z.string().min(1).optional(),
STRIPE_PRO_STORAGE_YEARLY_PRICE_ID: z.string().min(1).optional(),
STRIPE_STARTER_PRICE_ID: z.string().min(1).optional(),
STRIPE_STARTER_CHATS_PRICE_ID: z.string().min(1).optional(),
STRIPE_PRO_PRICE_ID: z.string().min(1).optional(),
STRIPE_PRO_CHATS_PRICE_ID: z.string().min(1).optional(),
},
client: {
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: z.string().min(1).optional(),

View File

@ -1,33 +0,0 @@
import { env } from '@typebot.io/env'
import { Plan } from '@typebot.io/prisma'
export const priceIds = {
[Plan.STARTER]: {
base: {
monthly: env.STRIPE_STARTER_MONTHLY_PRICE_ID,
yearly: env.STRIPE_STARTER_YEARLY_PRICE_ID,
},
chats: {
monthly: env.STRIPE_STARTER_CHATS_MONTHLY_PRICE_ID,
yearly: env.STRIPE_STARTER_CHATS_YEARLY_PRICE_ID,
},
storage: {
monthly: env.STRIPE_STARTER_STORAGE_MONTHLY_PRICE_ID,
yearly: env.STRIPE_STARTER_STORAGE_YEARLY_PRICE_ID,
},
},
[Plan.PRO]: {
base: {
monthly: env.STRIPE_PRO_MONTHLY_PRICE_ID,
yearly: env.STRIPE_PRO_YEARLY_PRICE_ID,
},
chats: {
monthly: env.STRIPE_PRO_CHATS_MONTHLY_PRICE_ID,
yearly: env.STRIPE_PRO_CHATS_YEARLY_PRICE_ID,
},
storage: {
monthly: env.STRIPE_PRO_STORAGE_MONTHLY_PRICE_ID,
yearly: env.STRIPE_PRO_STORAGE_YEARLY_PRICE_ID,
},
},
}

View File

@ -0,0 +1,167 @@
import { Plan } from '@typebot.io/prisma'
import type { Stripe } from 'stripe'
export const prices = {
[Plan.STARTER]: 39,
[Plan.PRO]: 89,
} as const
export const chatsLimits = {
[Plan.FREE]: 200,
[Plan.STARTER]: 2000,
[Plan.PRO]: 10000,
} as const
export const seatsLimits = {
[Plan.FREE]: 1,
[Plan.OFFERED]: 1,
[Plan.STARTER]: 2,
[Plan.PRO]: 5,
[Plan.LIFETIME]: 8,
} as const
export const starterChatTiers = [
{
up_to: 2000,
flat_amount: 0,
},
{
up_to: 2500,
flat_amount: 1000,
},
{
up_to: 3000,
flat_amount: 1000,
},
{
up_to: 3500,
flat_amount: 1000,
},
{
up_to: 4000,
flat_amount: 1000,
},
{
up_to: 'inf',
unit_amount: 2,
},
] satisfies Stripe.PriceCreateParams.Tier[]
export const proChatTiers = [
{
up_to: 10000,
flat_amount: 0,
},
{
up_to: 15000,
flat_amount: 5000,
},
{
up_to: 20000,
flat_amount: 4500,
},
{
up_to: 30000,
flat_amount: 8500,
},
{
up_to: 40000,
flat_amount: 8000,
},
{
up_to: 50000,
flat_amount: 7500,
},
{
up_to: 60000,
flat_amount: 7225,
},
{
up_to: 70000,
flat_amount: 7000,
},
{
up_to: 80000,
flat_amount: 6800,
},
{
up_to: 90000,
flat_amount: 6600,
},
{
up_to: 100000,
flat_amount: 6400,
},
{
up_to: 120000,
flat_amount: 12400,
},
{
up_to: 140000,
flat_amount: 12000,
},
{
up_to: 160000,
flat_amount: 11800,
},
{
up_to: 180000,
flat_amount: 11600,
},
{
up_to: 200000,
flat_amount: 11400,
},
{
up_to: 300000,
flat_amount: 55000,
},
{
up_to: 400000,
flat_amount: 53000,
},
{
up_to: 500000,
flat_amount: 51000,
},
{
up_to: 600000,
flat_amount: 50000,
},
{
up_to: 700000,
flat_amount: 49000,
},
{
up_to: 800000,
flat_amount: 48000,
},
{
up_to: 900000,
flat_amount: 47000,
},
{
up_to: 1000000,
flat_amount: 46000,
},
{
up_to: 1200000,
flat_amount: 91400,
},
{
up_to: 1400000,
flat_amount: 90800,
},
{
up_to: 1600000,
flat_amount: 90000,
},
{
up_to: 1800000,
flat_amount: 89400,
},
{
up_to: 'inf',
unit_amount_decimal: '0.442',
},
] satisfies Stripe.PriceCreateParams.Tier[]

View File

@ -0,0 +1,21 @@
import { guessIfUserIsEuropean } from './guessIfUserIsEuropean'
type FormatPriceParams = {
currency?: 'eur' | 'usd'
maxFractionDigits?: number
}
export const formatPrice = (
price: number,
{ currency, maxFractionDigits = 0 }: FormatPriceParams = {
maxFractionDigits: 0,
}
) => {
const isEuropean = guessIfUserIsEuropean()
const formatter = new Intl.NumberFormat(isEuropean ? 'fr-FR' : 'en-US', {
style: 'currency',
currency: currency?.toUpperCase() ?? (isEuropean ? 'EUR' : 'USD'),
maximumFractionDigits: maxFractionDigits,
})
return formatter.format(price)
}

View File

@ -0,0 +1,19 @@
import { Plan } from '@typebot.io/prisma'
import { chatsLimits } from './constants'
import { Workspace } from '@typebot.io/schemas'
export const getChatsLimit = ({
plan,
customChatsLimit,
}: Pick<Workspace, 'plan'> & {
customChatsLimit?: Workspace['customChatsLimit']
}) => {
if (
plan === Plan.UNLIMITED ||
plan === Plan.LIFETIME ||
plan === Plan.OFFERED
)
return -1
if (plan === Plan.CUSTOM) return customChatsLimit ?? -1
return chatsLimits[plan]
}

View File

@ -0,0 +1,12 @@
import { Workspace } from '@typebot.io/schemas'
import { seatsLimits } from './constants'
import { Plan } from '@typebot.io/prisma'
export const getSeatsLimit = ({
plan,
customSeatsLimit,
}: Pick<Workspace, 'plan' | 'customSeatsLimit'>) => {
if (plan === Plan.UNLIMITED) return -1
if (plan === Plan.CUSTOM) return customSeatsLimit ? customSeatsLimit : -1
return seatsLimits[plan]
}

View File

@ -0,0 +1,56 @@
const europeanUnionCountryCodes = [
'AT',
'BE',
'BG',
'CY',
'CZ',
'DE',
'DK',
'EE',
'ES',
'FI',
'FR',
'GR',
'HR',
'HU',
'IE',
'IT',
'LT',
'LU',
'LV',
'MT',
'NL',
'PL',
'PT',
'RO',
'SE',
'SI',
'SK',
]
const europeanUnionExclusiveLanguageCodes = [
'fr',
'de',
'it',
'el',
'pl',
'fi',
'nl',
'hr',
'cs',
'hu',
'ro',
'sl',
'sv',
'bg',
]
export const guessIfUserIsEuropean = () => {
if (typeof window === 'undefined') return false
return window.navigator.languages.some((language) => {
const [languageCode, countryCode] = language.split('-')
return countryCode
? europeanUnionCountryCodes.includes(countryCode)
: europeanUnionExclusiveLanguageCodes.includes(languageCode)
})
}

View File

@ -27,6 +27,7 @@
"@udecode/plate-common": "^21.1.5",
"got": "12.6.0",
"minio": "7.1.3",
"remark-slate": "^1.8.6"
"remark-slate": "^1.8.6",
"stripe": "12.13.0"
}
}

View File

@ -1,177 +0,0 @@
import type { Workspace } from '@typebot.io/prisma'
import { Plan } from '@typebot.io/prisma'
const infinity = -1
export const prices = {
[Plan.STARTER]: 39,
[Plan.PRO]: 89,
} as const
export const chatsLimit = {
[Plan.FREE]: { totalIncluded: 200 },
[Plan.STARTER]: {
graduatedPrice: [
{ totalIncluded: 2000, price: 0 },
{
totalIncluded: 2500,
price: 10,
},
{
totalIncluded: 3000,
price: 20,
},
{
totalIncluded: 3500,
price: 30,
},
],
},
[Plan.PRO]: {
graduatedPrice: [
{ totalIncluded: 10000, price: 0 },
{ totalIncluded: 15000, price: 50 },
{ totalIncluded: 25000, price: 150 },
{ totalIncluded: 50000, price: 400 },
],
},
[Plan.CUSTOM]: {
totalIncluded: 2000,
increaseStep: {
amount: 500,
price: 10,
},
},
[Plan.OFFERED]: { totalIncluded: infinity },
[Plan.LIFETIME]: { totalIncluded: infinity },
[Plan.UNLIMITED]: { totalIncluded: infinity },
} as const
export const seatsLimit = {
[Plan.FREE]: { totalIncluded: 1 },
[Plan.STARTER]: {
totalIncluded: 2,
},
[Plan.PRO]: {
totalIncluded: 5,
},
[Plan.CUSTOM]: {
totalIncluded: 2,
},
[Plan.OFFERED]: { totalIncluded: 2 },
[Plan.LIFETIME]: { totalIncluded: 8 },
[Plan.UNLIMITED]: { totalIncluded: infinity },
} as const
export const getChatsLimit = ({
plan,
additionalChatsIndex,
customChatsLimit,
}: Pick<Workspace, 'additionalChatsIndex' | 'plan' | 'customChatsLimit'>) => {
if (customChatsLimit) return customChatsLimit
const totalIncluded =
plan === Plan.STARTER || plan === Plan.PRO
? chatsLimit[plan].graduatedPrice[additionalChatsIndex].totalIncluded
: chatsLimit[plan].totalIncluded
return totalIncluded
}
export const getSeatsLimit = ({
plan,
customSeatsLimit,
}: Pick<Workspace, 'plan' | 'customSeatsLimit'>) => {
if (customSeatsLimit) return customSeatsLimit
return seatsLimit[plan].totalIncluded
}
export const isSeatsLimitReached = ({
existingMembersAndInvitationsCount,
plan,
customSeatsLimit,
}: {
existingMembersAndInvitationsCount: number
} & Pick<Workspace, 'plan' | 'customSeatsLimit'>) => {
const seatsLimit = getSeatsLimit({ plan, customSeatsLimit })
return (
seatsLimit !== infinity && seatsLimit <= existingMembersAndInvitationsCount
)
}
export const computePrice = (
plan: Plan,
selectedTotalChatsIndex: number,
frequency: 'monthly' | 'yearly'
) => {
if (plan !== Plan.STARTER && plan !== Plan.PRO) return
const price =
prices[plan] +
chatsLimit[plan].graduatedPrice[selectedTotalChatsIndex].price
return frequency === 'monthly' ? price : price - price * 0.16
}
const europeanUnionCountryCodes = [
'AT',
'BE',
'BG',
'CY',
'CZ',
'DE',
'DK',
'EE',
'ES',
'FI',
'FR',
'GR',
'HR',
'HU',
'IE',
'IT',
'LT',
'LU',
'LV',
'MT',
'NL',
'PL',
'PT',
'RO',
'SE',
'SI',
'SK',
]
const europeanUnionExclusiveLanguageCodes = [
'fr',
'de',
'it',
'el',
'pl',
'fi',
'nl',
'hr',
'cs',
'hu',
'ro',
'sl',
'sv',
'bg',
]
export const guessIfUserIsEuropean = () => {
if (typeof window === 'undefined') return false
return window.navigator.languages.some((language) => {
const [languageCode, countryCode] = language.split('-')
return countryCode
? europeanUnionCountryCodes.includes(countryCode)
: europeanUnionExclusiveLanguageCodes.includes(languageCode)
})
}
export const formatPrice = (price: number, currency?: 'eur' | 'usd') => {
const isEuropean = guessIfUserIsEuropean()
const formatter = new Intl.NumberFormat(isEuropean ? 'fr-FR' : 'en-US', {
style: 'currency',
currency: currency?.toUpperCase() ?? (isEuropean ? 'EUR' : 'USD'),
maximumFractionDigits: 0, // (causes 2500.99 to be printed as $2,501)
})
return formatter.format(price)
}

View File

@ -1,7 +1,10 @@
import { z } from 'zod'
export const subscriptionSchema = z.object({
isYearly: z.boolean(),
currentBillingPeriod: z.object({
start: z.date(),
end: z.date(),
}),
currency: z.enum(['eur', 'usd']),
cancelDate: z.date().optional(),
status: z.enum(['active', 'past_due']),

View File

@ -62,7 +62,6 @@ const subscriptionUpdatedEventSchema = workspaceEvent.merge(
name: z.literal('Subscription updated'),
data: z.object({
plan: z.nativeEnum(Plan),
additionalChatsIndex: z.number(),
}),
})
)

View File

@ -4,25 +4,26 @@ import {
PrismaClient,
WorkspaceRole,
} from '@typebot.io/prisma'
import { isDefined } from '@typebot.io/lib'
import { getChatsLimit } from '@typebot.io/lib/pricing'
import { isDefined, isEmpty } from '@typebot.io/lib'
import { getChatsLimit } from '@typebot.io/lib/billing/getChatsLimit'
import { getUsage } from '@typebot.io/lib/api/getUsage'
import { promptAndSetEnvironment } from './utils'
import { Workspace } from '@typebot.io/schemas'
import { sendAlmostReachedChatsLimitEmail } from '@typebot.io/emails/src/emails/AlmostReachedChatsLimitEmail'
import { sendReachedChatsLimitEmail } from '@typebot.io/emails/src/emails/ReachedChatsLimitEmail'
import { TelemetryEvent } from '@typebot.io/schemas/features/telemetry'
import { sendTelemetryEvents } from '@typebot.io/lib/telemetry/sendTelemetryEvent'
import Stripe from 'stripe'
import { createId } from '@paralleldrive/cuid2'
const prisma = new PrismaClient()
const LIMIT_EMAIL_TRIGGER_PERCENT = 0.8
const LIMIT_EMAIL_TRIGGER_PERCENT = 0.75
type WorkspaceForDigest = Pick<
Workspace,
| 'id'
| 'plan'
| 'name'
| 'customChatsLimit'
| 'additionalChatsIndex'
| 'isQuarantined'
| 'chatsLimitFirstEmailSentAt'
| 'chatsLimitSecondEmailSentAt'
@ -32,12 +33,40 @@ type WorkspaceForDigest = Pick<
})[]
}
export const sendTotalResultsDigest = async () => {
type ResultWithWorkspace = {
userId: string
workspace: {
id: string
typebots: {
id: string
}[]
members: {
user: {
id: string
email: string | null
}
role: WorkspaceRole
}[]
additionalStorageIndex: number
customChatsLimit: number | null
customStorageLimit: number | null
plan: Plan
isQuarantined: boolean
stripeId: string | null
}
typebotId: string
totalResultsYesterday: number
isFirstOfKind: true | undefined
}
export const checkAndReportChatsUsage = async () => {
await promptAndSetEnvironment('production')
console.log('Get collected results from the last hour...')
const hourAgo = new Date(Date.now() - 1000 * 60 * 60)
const zeroedMinutesHour = new Date()
zeroedMinutesHour.setUTCMinutes(0, 0, 0)
const hourAgo = new Date(zeroedMinutesHour.getTime() - 1000 * 60 * 60)
const results = await prisma.result.groupBy({
by: ['typebotId'],
@ -47,6 +76,7 @@ export const sendTotalResultsDigest = async () => {
where: {
hasStarted: true,
createdAt: {
lt: zeroedMinutesHour,
gte: hourAgo,
},
},
@ -69,11 +99,11 @@ export const sendTotalResultsDigest = async () => {
},
select: {
id: true,
name: true,
typebots: { select: { id: true } },
members: {
select: { user: { select: { id: true, email: true } }, role: true },
},
additionalChatsIndex: true,
additionalStorageIndex: true,
customChatsLimit: true,
customStorageLimit: true,
@ -81,6 +111,7 @@ export const sendTotalResultsDigest = async () => {
isQuarantined: true,
chatsLimitFirstEmailSentAt: true,
chatsLimitSecondEmailSentAt: true,
stripeId: true,
},
})
@ -110,9 +141,27 @@ export const sendTotalResultsDigest = async () => {
.map((result) => result.workspace)
)
console.log(`Send ${events.length} auto quarantine events...`)
await reportUsageToStripe(resultsWithWorkspaces)
await sendTelemetryEvents(events)
const newResultsCollectedEvents = resultsWithWorkspaces.map(
(result) =>
({
name: 'New results collected',
userId: result.userId,
workspaceId: result.workspace.id,
typebotId: result.typebotId,
data: {
total: result.totalResultsYesterday,
isFirstOfKind: result.isFirstOfKind,
},
} satisfies TelemetryEvent)
)
console.log(
`Send ${newResultsCollectedEvents.length} new results events and ${events.length} auto quarantine events...`
)
await sendTelemetryEvents(events.concat(newResultsCollectedEvents))
}
const sendAlertIfLimitReached = async (
@ -144,7 +193,7 @@ const sendAlertIfLimitReached = async (
to,
usagePercent: Math.round((totalChatsUsed / chatsLimit) * 100),
chatsLimit,
url: `https://app.typebot.io/typebots?workspaceId=${workspace.id}`,
workspaceName: workspace.name,
})
await prisma.workspace.updateMany({
where: { id: workspace.id },
@ -155,32 +204,7 @@ const sendAlertIfLimitReached = async (
}
}
if (
chatsLimit > 0 &&
totalChatsUsed >= chatsLimit &&
!workspace.chatsLimitSecondEmailSentAt
) {
const to = workspace.members
.filter((member) => member.role === WorkspaceRole.ADMIN)
.map((member) => member.user.email)
.filter(isDefined)
try {
console.log(`Send reached chats limit email to ${to.join(', ')}...`)
await sendReachedChatsLimitEmail({
to,
chatsLimit,
url: `https://app.typebot.io/typebots?workspaceId=${workspace.id}`,
})
await prisma.workspace.updateMany({
where: { id: workspace.id },
data: { chatsLimitSecondEmailSentAt: new Date() },
})
} catch (err) {
console.error(err)
}
}
if (totalChatsUsed > chatsLimit * 3 && workspace.plan === Plan.FREE) {
if (totalChatsUsed > chatsLimit * 1.5 && workspace.plan === Plan.FREE) {
console.log(`Automatically quarantine workspace ${workspace.id}...`)
await prisma.workspace.updateMany({
where: { id: workspace.id },
@ -207,4 +231,67 @@ const sendAlertIfLimitReached = async (
return events
}
sendTotalResultsDigest().then()
const reportUsageToStripe = async (
resultsWithWorkspaces: (Pick<ResultWithWorkspace, 'totalResultsYesterday'> & {
workspace: Pick<
ResultWithWorkspace['workspace'],
'id' | 'plan' | 'stripeId'
>
})[]
) => {
if (isEmpty(process.env.STRIPE_SECRET_KEY))
throw new Error('Missing STRIPE_SECRET_KEY env variable')
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2022-11-15',
})
for (const result of resultsWithWorkspaces.filter(
(result) =>
result.workspace.plan === 'STARTER' || result.workspace.plan === 'PRO'
)) {
if (!result.workspace.stripeId)
throw new Error(
`Found paid workspace without a stripeId: ${result.workspace.stripeId}`
)
const subscriptions = await stripe.subscriptions.list({
customer: result.workspace.stripeId,
})
const currentSubscription = subscriptions.data
.filter((sub) => ['past_due', 'active'].includes(sub.status))
.sort((a, b) => a.created - b.created)
.shift()
if (!currentSubscription)
throw new Error(
`Found paid workspace without a subscription: ${result.workspace.stripeId}`
)
const subscriptionItem = currentSubscription.items.data.find(
(item) =>
item.price.id === process.env.STRIPE_STARTER_CHATS_PRICE_ID ||
item.price.id === process.env.STRIPE_PRO_CHATS_PRICE_ID
)
if (!subscriptionItem)
throw new Error(
`Could not find subscription item for workspace ${result.workspace.id}`
)
const idempotencyKey = createId()
await stripe.subscriptionItems.createUsageRecord(
subscriptionItem.id,
{
quantity: result.totalResultsYesterday,
timestamp: 'now',
},
{
idempotencyKey,
}
)
}
}
checkAndReportChatsUsage().then()

View File

@ -180,17 +180,12 @@ const resetBillingProps = async () => {
{
chatsLimitFirstEmailSentAt: { not: null },
},
{
storageLimitFirstEmailSentAt: { not: null },
},
],
},
data: {
isQuarantined: false,
chatsLimitFirstEmailSentAt: null,
storageLimitFirstEmailSentAt: null,
chatsLimitSecondEmailSentAt: null,
storageLimitSecondEmailSentAt: null,
},
})
console.log(`Resetted ${count} workspaces.`)

View File

@ -0,0 +1,50 @@
import Stripe from 'stripe'
import { promptAndSetEnvironment } from './utils'
import {
proChatTiers,
starterChatTiers,
} from '@typebot.io/lib/billing/constants'
const chatsProductId = 'prod_MVXtq5sATQzIcM'
const createChatsPrices = async () => {
await promptAndSetEnvironment()
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2022-11-15',
})
await stripe.prices.create({
currency: 'usd',
billing_scheme: 'tiered',
recurring: { interval: 'month', usage_type: 'metered' },
tiers: starterChatTiers,
tiers_mode: 'volume',
tax_behavior: 'exclusive',
product: chatsProductId,
currency_options: {
eur: {
tax_behavior: 'exclusive',
tiers: starterChatTiers,
},
},
})
await stripe.prices.create({
currency: 'usd',
billing_scheme: 'tiered',
recurring: { interval: 'month', usage_type: 'metered' },
tiers: proChatTiers,
tiers_mode: 'volume',
tax_behavior: 'exclusive',
product: chatsProductId,
currency_options: {
eur: {
tax_behavior: 'exclusive',
tiers: proChatTiers,
},
},
})
}
createChatsPrices()

View File

@ -39,7 +39,6 @@ const inspectUser = async () => {
user: { email: { not: response.email } },
},
},
additionalChatsIndex: true,
additionalStorageIndex: true,
typebots: {
orderBy: {
@ -82,10 +81,6 @@ const inspectUser = async () => {
console.log(' - Name:', workspace.workspace.name)
console.log(' Plan:', workspace.workspace.plan)
console.log(' Members:', workspace.workspace.members.length + 1)
console.log(
' Additional chats:',
workspace.workspace.additionalChatsIndex
)
console.log(
' Additional storage:',
workspace.workspace.additionalStorageIndex

View File

@ -0,0 +1,278 @@
import { PrismaClient } from '@typebot.io/prisma'
import { promptAndSetEnvironment } from './utils'
import { Stripe } from 'stripe'
import { createId } from '@paralleldrive/cuid2'
import { writeFileSync } from 'fs'
const migrateSubscriptionsToUsageBased = async () => {
await promptAndSetEnvironment()
const prisma = new PrismaClient()
if (
!process.env.STRIPE_STARTER_CHATS_PRICE_ID ||
!process.env.STRIPE_PRO_CHATS_PRICE_ID ||
!process.env.STRIPE_SECRET_KEY ||
!process.env.STRIPE_STARTER_PRICE_ID ||
!process.env.STRIPE_PRO_PRICE_ID
)
throw new Error('Missing some env variables')
const {
starterChatsPriceId,
proChatsPriceId,
secretKey,
starterPriceId,
proPriceId,
starterYearlyPriceId,
proYearlyPriceId,
} = {
starterChatsPriceId: process.env.STRIPE_STARTER_CHATS_PRICE_ID,
proChatsPriceId: process.env.STRIPE_PRO_CHATS_PRICE_ID,
secretKey: process.env.STRIPE_SECRET_KEY,
starterPriceId: process.env.STRIPE_STARTER_PRICE_ID,
proPriceId: process.env.STRIPE_PRO_PRICE_ID,
starterYearlyPriceId: process.env.STRIPE_STARTER_YEARLY_PRICE_ID,
proYearlyPriceId: process.env.STRIPE_PRO_YEARLY_PRICE_ID,
}
const workspacesWithPaidPlan = await prisma.workspace.findMany({
where: {
plan: {
in: ['PRO', 'STARTER'],
},
isSuspended: false,
},
select: {
plan: true,
name: true,
id: true,
stripeId: true,
isQuarantined: true,
members: {
select: {
user: {
select: { email: true },
},
},
},
},
})
writeFileSync(
'./workspacesWithPaidPlan.json',
JSON.stringify(workspacesWithPaidPlan, null, 2)
)
const stripe = new Stripe(secretKey, {
apiVersion: '2022-11-15',
})
const todayMidnight = new Date()
todayMidnight.setUTCHours(0, 0, 0, 0)
const failedWorkspaces = []
const workspacesWithoutSubscription = []
const workspacesWithoutStripeId = []
let i = 0
for (const workspace of workspacesWithPaidPlan) {
i += 1
console.log(
`(${i} / ${workspacesWithPaidPlan.length})`,
'Migrating workspace:',
workspace.id,
workspace.name,
workspace.stripeId,
JSON.stringify(workspace.members.map((member) => member.user.email))
)
if (!workspace.stripeId) {
console.log('No stripe ID, skipping...')
workspacesWithoutStripeId.push(workspace)
writeFileSync(
'./workspacesWithoutStripeId.json',
JSON.stringify(workspacesWithoutStripeId, null, 2)
)
continue
}
const subscriptions = await stripe.subscriptions.list({
customer: workspace.stripeId,
})
const currentSubscription = subscriptions.data
.filter((sub) => ['past_due', 'active'].includes(sub.status))
.sort((a, b) => a.created - b.created)
.shift()
if (!currentSubscription) {
console.log('No current subscription in workspace:', workspace.id)
workspacesWithoutSubscription.push(workspace)
writeFileSync(
'./workspacesWithoutSubscription.json',
JSON.stringify(workspacesWithoutSubscription)
)
continue
}
if (
currentSubscription.items.data.find(
(item) =>
item.price.id === starterChatsPriceId ||
item.price.id === proChatsPriceId
)
) {
console.log(
'Already migrated to usage based billing for workspace. Skipping...'
)
continue
}
if (
!currentSubscription.items.data.find(
(item) =>
item.price.id === starterPriceId ||
item.price.id === proPriceId ||
item.price.id === starterYearlyPriceId ||
item.price.id === proYearlyPriceId
)
) {
console.log(
'Could not find STARTER or PRO plan in items for workspace:',
workspace.id
)
failedWorkspaces.push(workspace)
writeFileSync(
'./failedWorkspaces.json',
JSON.stringify(failedWorkspaces, null, 2)
)
continue
}
const newSubscription = await stripe.subscriptions.update(
currentSubscription.id,
{
items: [
...currentSubscription.items.data.flatMap<Stripe.SubscriptionUpdateParams.Item>(
(item) => {
if (
item.price.id === starterPriceId ||
item.price.id === proPriceId
)
return {
id: item.id,
price: item.price.id,
quantity: item.quantity,
}
if (
item.price.id === starterYearlyPriceId ||
item.price.id === proYearlyPriceId
)
return [
{
id: item.id,
price: item.price.id,
quantity: item.quantity,
deleted: true,
},
{
price:
workspace.plan === 'STARTER'
? starterPriceId
: proPriceId,
quantity: 1,
},
]
return {
id: item.id,
price: item.price.id,
quantity: item.quantity,
deleted: true,
}
}
),
{
price:
workspace.plan === 'STARTER'
? starterChatsPriceId
: proChatsPriceId,
},
],
}
)
const totalResults = await prisma.result.count({
where: {
typebot: { workspaceId: workspace.id },
hasStarted: true,
createdAt: {
gte: new Date(newSubscription.current_period_start * 1000),
lt: todayMidnight,
},
},
})
if (workspace.plan === 'STARTER' && totalResults >= 4000) {
console.log(
'Workspace has more than 4000 chats, automatically upgrading to PRO plan'
)
const currentPlanItemId = newSubscription?.items.data.find((item) =>
[starterPriceId, proPriceId].includes(item.price.id)
)?.id
if (!currentPlanItemId)
throw new Error(`Could not find current plan item ID for workspace`)
await stripe.subscriptions.update(newSubscription.id, {
items: [
{
id: currentPlanItemId,
price: proPriceId,
quantity: 1,
},
],
})
await prisma.workspace.update({
where: { id: workspace.id },
data: {
plan: 'PRO',
},
})
}
const subscriptionItem = newSubscription.items.data.find(
(item) =>
item.price.id === starterChatsPriceId ||
item.price.id === proChatsPriceId
)
if (!subscriptionItem)
throw new Error(
`Could not find subscription item for workspace ${workspace.id}`
)
const idempotencyKey = createId()
console.log('Reporting total results:', totalResults)
await stripe.subscriptionItems.createUsageRecord(
subscriptionItem.id,
{
quantity: totalResults,
timestamp: 'now',
},
{
idempotencyKey,
}
)
if (workspace.isQuarantined) {
await prisma.workspace.update({
where: { id: workspace.id },
data: {
isQuarantined: false,
},
})
}
}
}
migrateSubscriptionsToUsageBased()

View File

@ -13,9 +13,11 @@
"db:bulkUpdate": "tsx bulkUpdate.ts",
"db:fixTypebots": "tsx fixTypebots.ts",
"telemetry:sendTotalResultsDigest": "tsx sendTotalResultsDigest.ts",
"sendAlertEmails": "tsx sendAlertEmails.ts",
"checkAndReportChatsUsage": "tsx checkAndReportChatsUsage.ts",
"inspectUser": "tsx inspectUser.ts",
"checkSubscriptionsStatus": "tsx checkSubscriptionsStatus.ts"
"checkSubscriptionsStatus": "tsx checkSubscriptionsStatus.ts",
"createChatsPrices": "tsx createChatsPrices.ts",
"migrateSubscriptionsToUsageBased": "tsx migrateSubscriptionsToUsageBased.ts"
},
"devDependencies": {
"@typebot.io/emails": "workspace:*",
@ -31,5 +33,8 @@
"tsx": "3.12.7",
"typescript": "5.1.6",
"zod": "3.21.4"
},
"dependencies": {
"@paralleldrive/cuid2": "2.2.1"
}
}

View File

@ -1,200 +0,0 @@
import {
MemberInWorkspace,
PrismaClient,
WorkspaceRole,
} from '@typebot.io/prisma'
import { isDefined } from '@typebot.io/lib'
import { getChatsLimit } from '@typebot.io/lib/pricing'
import { promptAndSetEnvironment } from './utils'
import { TelemetryEvent } from '@typebot.io/schemas/features/telemetry'
import { sendTelemetryEvents } from '@typebot.io/lib/telemetry/sendTelemetryEvent'
import { Workspace } from '@typebot.io/schemas'
const prisma = new PrismaClient()
type WorkspaceForDigest = Pick<
Workspace,
| 'id'
| 'plan'
| 'customChatsLimit'
| 'customStorageLimit'
| 'additionalChatsIndex'
| 'additionalStorageIndex'
| 'isQuarantined'
> & {
members: (Pick<MemberInWorkspace, 'role'> & {
user: { id: string; email: string | null }
})[]
}
export const sendTotalResultsDigest = async () => {
await promptAndSetEnvironment('production')
console.log("Generating total results yesterday's digest...")
const todayMidnight = new Date()
todayMidnight.setUTCHours(0, 0, 0, 0)
const yesterday = new Date(todayMidnight)
yesterday.setDate(yesterday.getDate() - 1)
const results = await prisma.result.groupBy({
by: ['typebotId'],
_count: {
_all: true,
},
where: {
hasStarted: true,
createdAt: {
gte: yesterday,
lt: todayMidnight,
},
},
})
console.log(
`Found ${results.reduce(
(total, result) => total + result._count._all,
0
)} results collected yesterday.`
)
const workspaces = await prisma.workspace.findMany({
where: {
typebots: {
some: {
id: { in: results.map((result) => result.typebotId) },
},
},
},
select: {
id: true,
typebots: { select: { id: true } },
members: {
select: { user: { select: { id: true, email: true } }, role: true },
},
additionalChatsIndex: true,
additionalStorageIndex: true,
customChatsLimit: true,
customStorageLimit: true,
plan: true,
isQuarantined: true,
},
})
const resultsWithWorkspaces = results
.flatMap((result) => {
const workspace = workspaces.find((workspace) =>
workspace.typebots.some((typebot) => typebot.id === result.typebotId)
)
if (!workspace) return
return workspace.members
.filter((member) => member.role !== WorkspaceRole.GUEST)
.map((member, memberIndex) => ({
userId: member.user.id,
workspace: workspace,
typebotId: result.typebotId,
totalResultsYesterday: result._count._all,
isFirstOfKind: memberIndex === 0 ? (true as const) : undefined,
}))
})
.filter(isDefined)
console.log('Computing workspaces limits...')
const workspaceLimitReachedEvents = await sendAlertIfLimitReached(
resultsWithWorkspaces
.filter((result) => result.isFirstOfKind)
.map((result) => result.workspace)
)
const newResultsCollectedEvents = resultsWithWorkspaces.map(
(result) =>
({
name: 'New results collected',
userId: result.userId,
workspaceId: result.workspace.id,
typebotId: result.typebotId,
data: {
total: result.totalResultsYesterday,
isFirstOfKind: result.isFirstOfKind,
},
} satisfies TelemetryEvent)
)
await sendTelemetryEvents(
workspaceLimitReachedEvents.concat(newResultsCollectedEvents)
)
console.log(
`Sent ${workspaceLimitReachedEvents.length} workspace limit reached events.`
)
console.log(
`Sent ${newResultsCollectedEvents.length} new results collected events.`
)
}
const sendAlertIfLimitReached = async (
workspaces: WorkspaceForDigest[]
): Promise<TelemetryEvent[]> => {
const events: TelemetryEvent[] = []
const taggedWorkspaces: string[] = []
for (const workspace of workspaces) {
if (taggedWorkspaces.includes(workspace.id) || workspace.isQuarantined)
continue
taggedWorkspaces.push(workspace.id)
const { totalChatsUsed } = await getUsage(workspace.id)
const chatsLimit = getChatsLimit(workspace)
if (chatsLimit > 0 && totalChatsUsed >= chatsLimit) {
events.push(
...workspace.members
.filter((member) => member.role === WorkspaceRole.ADMIN)
.map(
(member) =>
({
name: 'Workspace limit reached',
userId: member.user.id,
workspaceId: workspace.id,
data: {
totalChatsUsed,
chatsLimit,
},
} satisfies TelemetryEvent)
)
)
continue
}
}
return events
}
const getUsage = async (workspaceId: string) => {
const now = new Date()
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
const firstDayOfNextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1)
const typebots = await prisma.typebot.findMany({
where: {
workspace: {
id: workspaceId,
},
},
select: { id: true },
})
const [totalChatsUsed] = await Promise.all([
prisma.result.count({
where: {
typebotId: { in: typebots.map((typebot) => typebot.id) },
hasStarted: true,
createdAt: {
gte: firstDayOfMonth,
lt: firstDayOfNextMonth,
},
},
}),
])
return {
totalChatsUsed,
}
}
sendTotalResultsDigest().then()

7
pnpm-lock.yaml generated
View File

@ -1200,6 +1200,9 @@ importers:
remark-slate:
specifier: ^1.8.6
version: 1.8.6
stripe:
specifier: 12.13.0
version: 12.13.0
devDependencies:
'@paralleldrive/cuid2':
specifier: 2.2.1
@ -1277,6 +1280,10 @@ importers:
version: 5.1.6
packages/scripts:
dependencies:
'@paralleldrive/cuid2':
specifier: 2.2.1
version: 2.2.1
devDependencies:
'@typebot.io/emails':
specifier: workspace:*