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

@@ -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',