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: on:
schedule: schedule:
@@ -22,8 +22,9 @@ jobs:
SMTP_HOST: '${{ secrets.SMTP_HOST }}' SMTP_HOST: '${{ secrets.SMTP_HOST }}'
SMTP_PORT: '${{ secrets.SMTP_PORT }}' SMTP_PORT: '${{ secrets.SMTP_PORT }}'
NEXT_PUBLIC_SMTP_FROM: '${{ secrets.NEXT_PUBLIC_SMTP_FROM }}' NEXT_PUBLIC_SMTP_FROM: '${{ secrets.NEXT_PUBLIC_SMTP_FROM }}'
STRIPE_SECRET_KEY: '${{ secrets.STRIPE_SECRET_KEY }}'
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: pnpm/action-setup@v2.2.2 - uses: pnpm/action-setup@v2.2.2
- run: pnpm i --frozen-lockfile - 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 { Plan } from '@typebot.io/prisma'
import Stripe from 'stripe' import Stripe from 'stripe'
import { z } from 'zod' import { z } from 'zod'
import { parseSubscriptionItems } from '../helpers/parseSubscriptionItems'
import { isAdminWriteWorkspaceForbidden } from '@/features/workspace/helpers/isAdminWriteWorkspaceForbidden' import { isAdminWriteWorkspaceForbidden } from '@/features/workspace/helpers/isAdminWriteWorkspaceForbidden'
import { env } from '@typebot.io/env' import { env } from '@typebot.io/env'
@@ -26,14 +25,12 @@ export const createCheckoutSession = authenticatedProcedure
currency: z.enum(['usd', 'eur']), currency: z.enum(['usd', 'eur']),
plan: z.enum([Plan.STARTER, Plan.PRO]), plan: z.enum([Plan.STARTER, Plan.PRO]),
returnUrl: z.string(), returnUrl: z.string(),
additionalChats: z.number(),
vat: z vat: z
.object({ .object({
type: z.string(), type: z.string(),
value: z.string(), value: z.string(),
}) })
.optional(), .optional(),
isYearly: z.boolean(),
}) })
) )
.output( .output(
@@ -43,17 +40,7 @@ export const createCheckoutSession = authenticatedProcedure
) )
.mutation( .mutation(
async ({ async ({
input: { input: { vat, email, company, workspaceId, currency, plan, returnUrl },
vat,
email,
company,
workspaceId,
currency,
plan,
returnUrl,
additionalChats,
isYearly,
},
ctx: { user }, ctx: { user },
}) => { }) => {
if (!env.STRIPE_SECRET_KEY) if (!env.STRIPE_SECRET_KEY)
@@ -116,8 +103,6 @@ export const createCheckoutSession = authenticatedProcedure
currency, currency,
plan, plan,
returnUrl, returnUrl,
additionalChats,
isYearly,
}) })
if (!checkoutUrl) if (!checkoutUrl)
@@ -138,22 +123,12 @@ type Props = {
currency: 'usd' | 'eur' currency: 'usd' | 'eur'
plan: 'STARTER' | 'PRO' plan: 'STARTER' | 'PRO'
returnUrl: string returnUrl: string
additionalChats: number
isYearly: boolean
userId: string userId: string
} }
export const createCheckoutSessionUrl = export const createCheckoutSessionUrl =
(stripe: Stripe) => (stripe: Stripe) =>
async ({ async ({ customerId, workspaceId, currency, plan, returnUrl }: Props) => {
customerId,
workspaceId,
currency,
plan,
returnUrl,
additionalChats,
isYearly,
}: Props) => {
const session = await stripe.checkout.sessions.create({ const session = await stripe.checkout.sessions.create({
success_url: `${returnUrl}?stripe=${plan}&success=true`, success_url: `${returnUrl}?stripe=${plan}&success=true`,
cancel_url: `${returnUrl}?stripe=cancel`, cancel_url: `${returnUrl}?stripe=cancel`,
@@ -167,12 +142,25 @@ export const createCheckoutSessionUrl =
metadata: { metadata: {
workspaceId, workspaceId,
plan, plan,
additionalChats,
}, },
currency, currency,
billing_address_collection: 'required', billing_address_collection: 'required',
automatic_tax: { enabled: true }, 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 return session.url

View File

@@ -5,7 +5,6 @@ import Stripe from 'stripe'
import { z } from 'zod' import { z } from 'zod'
import { subscriptionSchema } from '@typebot.io/schemas/features/billing/subscription' import { subscriptionSchema } from '@typebot.io/schemas/features/billing/subscription'
import { isReadWorkspaceFobidden } from '@/features/workspace/helpers/isReadWorkspaceFobidden' import { isReadWorkspaceFobidden } from '@/features/workspace/helpers/isReadWorkspaceFobidden'
import { priceIds } from '@typebot.io/lib/api/pricing'
import { env } from '@typebot.io/env' import { env } from '@typebot.io/env'
export const getSubscription = authenticatedProcedure export const getSubscription = authenticatedProcedure
@@ -75,15 +74,14 @@ export const getSubscription = authenticatedProcedure
return { return {
subscription: { 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( status: subscriptionSchema.shape.status.parse(
currentSubscription.status 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', currency: currentSubscription.currency as 'usd' | 'eur',
cancelDate: currentSubscription.cancel_at cancelDate: currentSubscription.cancel_at
? new Date(currentSubscription.cancel_at * 1000) ? 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 { TRPCError } from '@trpc/server'
import { z } from 'zod' import { z } from 'zod'
import { isReadWorkspaceFobidden } from '@/features/workspace/helpers/isReadWorkspaceFobidden' import { isReadWorkspaceFobidden } from '@/features/workspace/helpers/isReadWorkspaceFobidden'
import { env } from '@typebot.io/env'
import Stripe from 'stripe'
export const getUsage = authenticatedProcedure export const getUsage = authenticatedProcedure
.meta({ .meta({
@@ -19,13 +21,15 @@ export const getUsage = authenticatedProcedure
workspaceId: z.string(), workspaceId: z.string(),
}) })
) )
.output(z.object({ totalChatsUsed: z.number() })) .output(z.object({ totalChatsUsed: z.number(), resetsAt: z.date() }))
.query(async ({ input: { workspaceId }, ctx: { user } }) => { .query(async ({ input: { workspaceId }, ctx: { user } }) => {
const workspace = await prisma.workspace.findFirst({ const workspace = await prisma.workspace.findFirst({
where: { where: {
id: workspaceId, id: workspaceId,
}, },
select: { select: {
stripeId: true,
plan: true,
members: { members: {
select: { select: {
userId: true, userId: true,
@@ -42,19 +46,63 @@ export const getUsage = authenticatedProcedure
message: 'Workspace not found', message: 'Workspace not found',
}) })
const now = new Date() if (
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1) !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({ const totalChatsUsed = await prisma.result.count({
where: { where: {
typebotId: { in: workspace.typebots.map((typebot) => typebot.id) }, typebotId: { in: workspace.typebots.map((typebot) => typebot.id) },
hasStarted: true, hasStarted: true,
createdAt: { createdAt: {
gte: firstDayOfMonth, gte: new Date(currentSubscription.current_period_start * 1000),
}, },
}, },
}) })
return { return {
totalChatsUsed, totalChatsUsed,
resetsAt: new Date(currentSubscription.current_period_end * 1000),
} }
}) })

View File

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

View File

@@ -5,15 +5,10 @@ import { TRPCError } from '@trpc/server'
import { Plan } from '@typebot.io/prisma' import { Plan } from '@typebot.io/prisma'
import { workspaceSchema } from '@typebot.io/schemas' import { workspaceSchema } from '@typebot.io/schemas'
import Stripe from 'stripe' import Stripe from 'stripe'
import { isDefined } from '@typebot.io/lib'
import { z } from 'zod' import { z } from 'zod'
import { getChatsLimit } from '@typebot.io/lib/pricing'
import { chatPriceIds } from './getSubscription'
import { createCheckoutSessionUrl } from './createCheckoutSession' import { createCheckoutSessionUrl } from './createCheckoutSession'
import { isAdminWriteWorkspaceForbidden } from '@/features/workspace/helpers/isAdminWriteWorkspaceForbidden' import { isAdminWriteWorkspaceForbidden } from '@/features/workspace/helpers/isAdminWriteWorkspaceForbidden'
import { getUsage } from '@typebot.io/lib/api/getUsage'
import { env } from '@typebot.io/env' import { env } from '@typebot.io/env'
import { priceIds } from '@typebot.io/lib/api/pricing'
export const updateSubscription = authenticatedProcedure export const updateSubscription = authenticatedProcedure
.meta({ .meta({
@@ -30,9 +25,7 @@ export const updateSubscription = authenticatedProcedure
returnUrl: z.string(), returnUrl: z.string(),
workspaceId: z.string(), workspaceId: z.string(),
plan: z.enum([Plan.STARTER, Plan.PRO]), plan: z.enum([Plan.STARTER, Plan.PRO]),
additionalChats: z.number(),
currency: z.enum(['usd', 'eur']), currency: z.enum(['usd', 'eur']),
isYearly: z.boolean(),
}) })
) )
.output( .output(
@@ -43,14 +36,7 @@ export const updateSubscription = authenticatedProcedure
) )
.mutation( .mutation(
async ({ async ({
input: { input: { workspaceId, plan, currency, returnUrl },
workspaceId,
plan,
additionalChats,
currency,
isYearly,
returnUrl,
},
ctx: { user }, ctx: { user },
}) => { }) => {
if (!env.STRIPE_SECRET_KEY) if (!env.STRIPE_SECRET_KEY)
@@ -81,6 +67,7 @@ export const updateSubscription = authenticatedProcedure
code: 'NOT_FOUND', code: 'NOT_FOUND',
message: 'Workspace not found', message: 'Workspace not found',
}) })
const stripe = new Stripe(env.STRIPE_SECRET_KEY, { const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: '2022-11-15', apiVersion: '2022-11-15',
}) })
@@ -91,39 +78,57 @@ export const updateSubscription = authenticatedProcedure
}) })
const subscription = data[0] as Stripe.Subscription | undefined const subscription = data[0] as Stripe.Subscription | undefined
const currentPlanItemId = subscription?.items.data.find((item) => const currentPlanItemId = subscription?.items.data.find((item) =>
[env.STRIPE_STARTER_PRODUCT_ID, env.STRIPE_PRO_PRODUCT_ID].includes( [env.STRIPE_STARTER_PRICE_ID, env.STRIPE_PRO_PRICE_ID].includes(
item.price.product.toString() item.price.id
) )
)?.id )?.id
const currentAdditionalChatsItemId = subscription?.items.data.find( const currentUsageItemId = subscription?.items.data.find(
(item) => chatPriceIds.includes(item.price.id) (item) =>
item.price.id === env.STRIPE_STARTER_CHATS_PRICE_ID ||
item.price.id === env.STRIPE_PRO_CHATS_PRICE_ID
)?.id )?.id
const frequency = isYearly ? 'yearly' : 'monthly'
const items = [ const items = [
{ {
id: currentPlanItemId, id: currentPlanItemId,
price: priceIds[plan].base[frequency], price:
plan === Plan.STARTER
? env.STRIPE_STARTER_PRICE_ID
: env.STRIPE_PRO_PRICE_ID,
quantity: 1, quantity: 1,
}, },
additionalChats === 0 && !currentAdditionalChatsItemId {
? undefined id: currentUsageItemId,
: { price:
id: currentAdditionalChatsItemId, plan === Plan.STARTER
price: priceIds[plan].chats[frequency], ? env.STRIPE_STARTER_CHATS_PRICE_ID
quantity: getChatsLimit({ : env.STRIPE_PRO_CHATS_PRICE_ID,
plan, },
additionalChatsIndex: additionalChats, ]
customChatsLimit: null,
}),
deleted: subscription ? additionalChats === 0 : undefined,
},
].filter(isDefined)
if (subscription) { 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, { await stripe.subscriptions.update(subscription.id, {
items, items,
proration_behavior: 'always_invoice', proration_behavior:
plan === 'PRO' ? 'always_invoice' : 'create_prorations',
}) })
} else { } else {
const checkoutUrl = await createCheckoutSessionUrl(stripe)({ const checkoutUrl = await createCheckoutSessionUrl(stripe)({
@@ -133,31 +138,16 @@ export const updateSubscription = authenticatedProcedure
currency, currency,
plan, plan,
returnUrl, returnUrl,
additionalChats,
isYearly,
}) })
return { checkoutUrl } 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({ const updatedWorkspace = await prisma.workspace.update({
where: { id: workspaceId }, where: { id: workspaceId },
data: { data: {
plan, plan,
additionalChatsIndex: additionalChats, isQuarantined: false,
isQuarantined,
}, },
}) })
@@ -168,7 +158,6 @@ export const updateSubscription = authenticatedProcedure
userId: user.id, userId: user.id,
data: { data: {
plan, 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=Plan Change Workspace')
await page.click('text=Settings & Members') await page.click('text=Settings & Members')
await page.click('text=Billing & Usage') await page.click('text=Billing & Usage')
await page.click('button >> text="2,000"') await expect(page.locator('text="$39"')).toBeVisible()
await page.click('button >> text="3,500"')
await page.click('button >> text="2"')
await page.click('button >> text="4"')
await page.locator('label span').first().click()
await expect(page.locator('text="$73"')).toBeVisible()
await page.click('button >> text=Upgrade >> nth=0') await page.click('button >> text=Upgrade >> nth=0')
await page.getByLabel('Company name').fill('Company LLC') await page.getByLabel('Company name').fill('Company LLC')
await page.getByRole('button', { name: 'Go to checkout' }).click() await page.getByRole('button', { name: 'Go to checkout' }).click()
await page.waitForNavigation() await page.waitForNavigation()
expect(page.url()).toContain('https://checkout.stripe.com') expect(page.url()).toContain('https://checkout.stripe.com')
await expect(page.locator('text=$73.00 >> nth=0')).toBeVisible() await expect(page.locator('text=$39 >> nth=0')).toBeVisible()
await expect(page.locator('text=$30.00 >> nth=0')).toBeVisible()
await expect(page.locator('text=$4.00 >> nth=0')).toBeVisible()
const stripeId = await addSubscriptionToWorkspace( const stripeId = await addSubscriptionToWorkspace(
planChangeWorkspaceId, planChangeWorkspaceId,
[ [
{ {
price: env.STRIPE_STARTER_MONTHLY_PRICE_ID, price: env.STRIPE_STARTER_PRICE_ID,
quantity: 1, quantity: 1,
}, },
{
price: env.STRIPE_STARTER_CHATS_PRICE_ID,
},
], ],
{ plan: Plan.STARTER, additionalChatsIndex: 0 } { plan: Plan.STARTER }
) )
// Update plan with additional quotas // Update plan with additional quotas
@@ -147,30 +143,9 @@ test('plan changes should work', async ({ page }) => {
await page.click('text=Billing & Usage') await page.click('text=Billing & Usage')
await expect(page.locator('text="/ 2,000"')).toBeVisible() await expect(page.locator('text="/ 2,000"')).toBeVisible()
await expect(page.getByText('/ 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 // Upgrade to PRO
await page.click('button >> text="10,000"') await expect(page.locator('text="$89"')).toBeVisible()
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 page.click('button >> text=Upgrade') await page.click('button >> text=Upgrade')
await expect( await expect(
page.locator('text="Workspace PRO plan successfully updated 🎉" >> nth=0') page.locator('text="Workspace PRO plan successfully updated 🎉" >> nth=0')
@@ -181,11 +156,12 @@ test('plan changes should work', async ({ page }) => {
page.waitForNavigation(), page.waitForNavigation(),
page.click('text="Billing portal"'), 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, timeout: 10000,
}) })
await expect(page.getByText('(×25000)')).toBeVisible()
await expect(page.getByText('(×15)')).toBeVisible()
await expect(page.locator('text="Add payment method"')).toBeVisible() await expect(page.locator('text="Add payment method"')).toBeVisible()
await cancelSubscription(stripeId) await cancelSubscription(stripeId)
@@ -212,9 +188,8 @@ test('should display invoices', async ({ page }) => {
await page.click('text=Billing & Usage') await page.click('text=Billing & Usage')
await expect(page.locator('text="Invoices"')).toBeVisible() await expect(page.locator('text="Invoices"')).toBeVisible()
await expect(page.locator('tr')).toHaveCount(4) await expect(page.locator('tr')).toHaveCount(4)
await expect(page.locator('text="$39.00"')).toBeVisible() await expect(page.getByText('$39.00')).toBeVisible()
await expect(page.locator('text="$34.00"')).toBeVisible() await expect(page.getByText('$50.00')).toBeVisible()
await expect(page.locator('text="$174.00"')).toBeVisible()
}) })
test('custom plans should work', async ({ page }) => { 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 { Plan } from '@typebot.io/prisma'
import { TextLink } from '@/components/TextLink' import { TextLink } from '@/components/TextLink'
import { useToast } from '@/hooks/useToast' import { useToast } from '@/hooks/useToast'
import { trpc } from '@/lib/trpc' import { trpc } from '@/lib/trpc'
import { guessIfUserIsEuropean } from '@typebot.io/lib/pricing'
import { Workspace } from '@typebot.io/schemas' import { Workspace } from '@typebot.io/schemas'
import { PreCheckoutModal, PreCheckoutModalProps } from './PreCheckoutModal' import { PreCheckoutModal, PreCheckoutModalProps } from './PreCheckoutModal'
import { useState } from 'react' import { useState } from 'react'
@@ -13,6 +12,7 @@ import { StarterPlanPricingCard } from './StarterPlanPricingCard'
import { ProPlanPricingCard } from './ProPlanPricingCard' import { ProPlanPricingCard } from './ProPlanPricingCard'
import { useScopedI18n } from '@/locales' import { useScopedI18n } from '@/locales'
import { StripeClimateLogo } from './StripeClimateLogo' import { StripeClimateLogo } from './StripeClimateLogo'
import { guessIfUserIsEuropean } from '@typebot.io/lib/billing/guessIfUserIsEuropean'
type Props = { type Props = {
workspace: Workspace workspace: Workspace
@@ -26,21 +26,12 @@ export const ChangePlanForm = ({ workspace, excludedPlans }: Props) => {
const { showToast } = useToast() const { showToast } = useToast()
const [preCheckoutPlan, setPreCheckoutPlan] = const [preCheckoutPlan, setPreCheckoutPlan] =
useState<PreCheckoutModalProps['selectedSubscription']>() useState<PreCheckoutModalProps['selectedSubscription']>()
const [isYearly, setIsYearly] = useState(true)
const trpcContext = trpc.useContext() const trpcContext = trpc.useContext()
const { data, refetch } = trpc.billing.getSubscription.useQuery( const { data, refetch } = trpc.billing.getSubscription.useQuery({
{ workspaceId: workspace.id,
workspaceId: workspace.id, })
},
{
onSuccess: ({ subscription }) => {
if (isYearly === false) return
setIsYearly(subscription?.isYearly ?? true)
},
}
)
const { mutate: updateSubscription, isLoading: isUpdatingSubscription } = const { mutate: updateSubscription, isLoading: isUpdatingSubscription } =
trpc.billing.updateSubscription.useMutation({ trpc.billing.updateSubscription.useMutation({
@@ -65,23 +56,15 @@ export const ChangePlanForm = ({ workspace, excludedPlans }: Props) => {
}, },
}) })
const handlePayClick = async ({ const handlePayClick = async (plan: 'STARTER' | 'PRO') => {
plan, if (!user) return
selectedChatsLimitIndex,
}: {
plan: 'STARTER' | 'PRO'
selectedChatsLimitIndex: number
}) => {
if (!user || selectedChatsLimitIndex === undefined) return
const newSubscription = { const newSubscription = {
plan, plan,
workspaceId: workspace.id, workspaceId: workspace.id,
additionalChats: selectedChatsLimitIndex,
currency: currency:
data?.subscription?.currency ?? data?.subscription?.currency ??
(guessIfUserIsEuropean() ? 'eur' : 'usd'), (guessIfUserIsEuropean() ? 'eur' : 'usd'),
isYearly,
} as const } as const
if (workspace.stripeId) { if (workspace.stripeId) {
updateSubscription({ updateSubscription({
@@ -122,26 +105,11 @@ export const ChangePlanForm = ({ workspace, excludedPlans }: Props) => {
)} )}
{data && ( {data && (
<Stack align="flex-end" spacing={6}> <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"> <HStack alignItems="stretch" spacing="4" w="full">
{excludedPlans?.includes('STARTER') ? null : ( {excludedPlans?.includes('STARTER') ? null : (
<StarterPlanPricingCard <StarterPlanPricingCard
workspace={workspace} currentPlan={workspace.plan}
currentSubscription={{ isYearly: data.subscription?.isYearly }} onPayClick={() => handlePayClick(Plan.STARTER)}
onPayClick={(props) =>
handlePayClick({ ...props, plan: Plan.STARTER })
}
isYearly={isYearly}
isLoading={isUpdatingSubscription} isLoading={isUpdatingSubscription}
currency={data.subscription?.currency} currency={data.subscription?.currency}
/> />
@@ -149,12 +117,8 @@ export const ChangePlanForm = ({ workspace, excludedPlans }: Props) => {
{excludedPlans?.includes('PRO') ? null : ( {excludedPlans?.includes('PRO') ? null : (
<ProPlanPricingCard <ProPlanPricingCard
workspace={workspace} currentPlan={workspace.plan}
currentSubscription={{ isYearly: data.subscription?.isYearly }} onPayClick={() => handlePayClick(Plan.PRO)}
onPayClick={(props) =>
handlePayClick({ ...props, plan: Plan.PRO })
}
isYearly={isYearly}
isLoading={isUpdatingSubscription} isLoading={isUpdatingSubscription}
currency={data.subscription?.currency} 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) => ( export const FeaturesList = ({ features, ...props }: FeaturesListProps) => (
<UnorderedList listStyleType="none" spacing={2} {...props}> <UnorderedList listStyleType="none" spacing={2} {...props}>
{features.map((feat, idx) => ( {features.map((feat, idx) => (
<Flex as={ListItem} key={idx} alignItems="center"> <Flex as={ListItem} key={idx}>
<ListIcon as={CheckIcon} /> <ListIcon as={CheckIcon} mt="1.5" />
{feat} {feat}
</Flex> </Flex>
))} ))}

View File

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

View File

@@ -3,226 +3,152 @@ import {
Heading, Heading,
chakra, chakra,
HStack, HStack,
Menu,
MenuButton,
Button, Button,
MenuList,
MenuItem,
Text, Text,
Tooltip, Tooltip,
Flex, Flex,
Tag, Tag,
useColorModeValue, useColorModeValue,
useDisclosure,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { ChevronLeftIcon } from '@/components/icons'
import { Plan } from '@typebot.io/prisma' 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 { FeaturesList } from './FeaturesList'
import { MoreInfoTooltip } from '@/components/MoreInfoTooltip' import { MoreInfoTooltip } from '@/components/MoreInfoTooltip'
import { useI18n, useScopedI18n } from '@/locales' 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 = { type Props = {
workspace: Pick< currentPlan: Plan
Workspace,
| 'additionalChatsIndex'
| 'plan'
| 'customChatsLimit'
| 'customStorageLimit'
| 'stripeId'
>
currentSubscription: {
isYearly?: boolean
}
currency?: 'usd' | 'eur' currency?: 'usd' | 'eur'
isLoading: boolean isLoading: boolean
isYearly: boolean onPayClick: () => void
onPayClick: (props: { selectedChatsLimitIndex: number }) => void
} }
export const ProPlanPricingCard = ({ export const ProPlanPricingCard = ({
workspace, currentPlan,
currentSubscription,
currency, currency,
isLoading, isLoading,
isYearly,
onPayClick, onPayClick,
}: Props) => { }: Props) => {
const t = useI18n() const t = useI18n()
const scopedT = useScopedI18n('billing.pricingCard') const scopedT = useScopedI18n('billing.pricingCard')
const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] = const { isOpen, onOpen, onClose } = useDisclosure()
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 getButtonLabel = () => { const getButtonLabel = () => {
if (selectedChatsLimitIndex === undefined) return '' if (currentPlan === Plan.PRO) return scopedT('upgradeButton.current')
if (workspace?.plan === Plan.PRO) {
if (isCurrentPlan) return scopedT('upgradeButton.current')
if (selectedChatsLimitIndex !== workspace.additionalChatsIndex)
return t('update')
}
return t('upgrade') return t('upgrade')
} }
const handlePayClick = async () => {
if (selectedChatsLimitIndex === undefined) return
onPayClick({
selectedChatsLimitIndex,
})
}
const price =
computePrice(
Plan.PRO,
selectedChatsLimitIndex ?? 0,
isYearly ? 'yearly' : 'monthly'
) ?? NaN
return ( return (
<Flex <>
p="6" <ChatsProTiersModal isOpen={isOpen} onClose={onClose} />{' '}
pos="relative" <Flex
h="full" p="6"
flexDir="column" pos="relative"
flex="1" h="full"
flexShrink={0} flexDir="column"
borderWidth="1px" flex="1"
borderColor={useColorModeValue('blue.500', 'blue.300')} flexShrink={0}
rounded="lg" borderWidth="1px"
> borderColor={useColorModeValue('blue.500', 'blue.300')}
<Flex justifyContent="center"> rounded="lg"
<Tag >
pos="absolute" <Flex justifyContent="center">
top="-10px" <Tag
colorScheme="blue" pos="absolute"
bg={useColorModeValue('blue.500', 'blue.400')} top="-10px"
variant="solid" colorScheme="blue"
fontWeight="semibold" bg={useColorModeValue('blue.500', 'blue.400')}
style={{ marginTop: 0 }} variant="solid"
> fontWeight="semibold"
{scopedT('pro.mostPopularLabel')} style={{ marginTop: 0 }}
</Tag> >
</Flex> {scopedT('pro.mostPopularLabel')}
<Stack justifyContent="space-between" h="full"> </Tag>
<Stack spacing="4" mt={2}> </Flex>
<Heading fontSize="2xl"> <Stack justifyContent="space-between" h="full">
{scopedT('heading', { <Stack spacing="4" mt={2}>
plan: ( <Heading fontSize="2xl">
<chakra.span color={useColorModeValue('blue.400', 'blue.300')}> {scopedT('heading', {
Pro plan: (
</chakra.span> <chakra.span
), color={useColorModeValue('blue.400', 'blue.300')}
})} >
</Heading> Pro
<Text>{scopedT('pro.description')}</Text> </chakra.span>
</Stack> ),
<Stack spacing="4"> })}
<Heading> </Heading>
{formatPrice(price, currency)} <Text>{scopedT('pro.description')}</Text>
<chakra.span fontSize="md">{scopedT('perMonth')}</chakra.span> </Stack>
</Heading> <Stack spacing="8">
<Text fontWeight="bold"> <Stack spacing="4">
<Tooltip <Heading>
label={ {formatPrice(prices.PRO, { currency })}
<FeaturesList <chakra.span fontSize="md">{scopedT('perMonth')}</chakra.span>
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
</Heading> </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 <Button
colorScheme="blue" colorScheme="blue"
variant="outline" variant="outline"
onClick={handlePayClick} onClick={onPayClick}
isLoading={isLoading} isLoading={isLoading}
isDisabled={isCurrentPlan} isDisabled={currentPlan === Plan.PRO}
> >
{getButtonLabel()} {getButtonLabel()}
</Button> </Button>
</Stack> </Stack>
</Stack> </Stack>
</Stack> </Flex>
</Flex> </>
) )
} }

View File

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

View File

@@ -12,9 +12,9 @@ import { AlertIcon } from '@/components/icons'
import { Workspace } from '@typebot.io/prisma' import { Workspace } from '@typebot.io/prisma'
import React from 'react' import React from 'react'
import { parseNumberWithCommas } from '@typebot.io/lib' import { parseNumberWithCommas } from '@typebot.io/lib'
import { getChatsLimit } from '@typebot.io/lib/pricing'
import { defaultQueryOptions, trpc } from '@/lib/trpc' import { defaultQueryOptions, trpc } from '@/lib/trpc'
import { useScopedI18n } from '@/locales' import { useScopedI18n } from '@/locales'
import { getChatsLimit } from '@typebot.io/lib/billing/getChatsLimit'
type Props = { type Props = {
workspace: Workspace workspace: Workspace
@@ -65,7 +65,7 @@ export const UsageProgressBars = ({ workspace }: Props) => {
</Tooltip> </Tooltip>
)} )}
<Text fontSize="sm" fontStyle="italic" color="gray.500"> <Text fontSize="sm" fontStyle="italic" color="gray.500">
{scopedT('chats.resetInfo')} (Resets on {data?.resetsAt.toLocaleDateString()})
</Text> </Text>
</HStack> </HStack>
@@ -90,9 +90,8 @@ export const UsageProgressBars = ({ workspace }: Props) => {
h="5px" h="5px"
value={chatsPercentage} value={chatsPercentage}
rounded="full" rounded="full"
hasStripe
isIndeterminate={isLoading} isIndeterminate={isLoading}
colorScheme={totalChatsUsed >= workspaceChatsLimit ? 'red' : 'blue'} colorScheme={'blue'}
/> />
</Stack> </Stack>
</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 { Plan } from '@typebot.io/prisma'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { guessIfUserIsEuropean } from '@typebot.io/lib/pricing'
import { DashboardHeader } from './DashboardHeader' import { DashboardHeader } from './DashboardHeader'
import { FolderContent } from '@/features/folders/components/FolderContent' import { FolderContent } from '@/features/folders/components/FolderContent'
import { TypebotDndProvider } from '@/features/folders/TypebotDndProvider' import { TypebotDndProvider } from '@/features/folders/TypebotDndProvider'
import { ParentModalProvider } from '@/features/graph/providers/ParentModalProvider' import { ParentModalProvider } from '@/features/graph/providers/ParentModalProvider'
import { trpc } from '@/lib/trpc' import { trpc } from '@/lib/trpc'
import { guessIfUserIsEuropean } from '@typebot.io/lib/billing/guessIfUserIsEuropean'
export const DashboardPage = () => { export const DashboardPage = () => {
const scopedT = useScopedI18n('dashboard') const scopedT = useScopedI18n('dashboard')
@@ -33,13 +33,11 @@ export const DashboardPage = () => {
}) })
useEffect(() => { useEffect(() => {
const { subscribePlan, chats, isYearly, claimCustomPlan } = const { subscribePlan, claimCustomPlan } = router.query as {
router.query as { subscribePlan: Plan | undefined
subscribePlan: Plan | undefined chats: string | undefined
chats: string | undefined claimCustomPlan: string | undefined
isYearly: string | undefined }
claimCustomPlan: string | undefined
}
if (claimCustomPlan && user?.email && workspace) { if (claimCustomPlan && user?.email && workspace) {
setIsLoading(true) setIsLoading(true)
createCustomCheckoutSession({ createCustomCheckoutSession({
@@ -53,9 +51,7 @@ export const DashboardPage = () => {
setPreCheckoutPlan({ setPreCheckoutPlan({
plan: subscribePlan as 'PRO' | 'STARTER', plan: subscribePlan as 'PRO' | 'STARTER',
workspaceId: workspace.id, workspaceId: workspace.id,
additionalChats: chats ? parseInt(chats) : 0,
currency: guessIfUserIsEuropean() ? 'eur' : 'usd', currency: guessIfUserIsEuropean() ? 'eur' : 'usd',
isYearly: isYearly === 'false' ? false : true,
}) })
} }
}, [createCustomCheckoutSession, router.query, user, workspace]) }, [createCustomCheckoutSession, router.query, user, workspace])

View File

@@ -18,7 +18,6 @@ import { useMemo } from 'react'
import { useStats } from '../hooks/useStats' import { useStats } from '../hooks/useStats'
import { ResultsProvider } from '../ResultsProvider' import { ResultsProvider } from '../ResultsProvider'
import { ResultsTableContainer } from './ResultsTableContainer' import { ResultsTableContainer } from './ResultsTableContainer'
import { UsageAlertBanners } from './UsageAlertBanners'
export const ResultsPage = () => { export const ResultsPage = () => {
const router = useRouter() const router = useRouter()
@@ -56,7 +55,6 @@ export const ResultsPage = () => {
} }
/> />
<TypebotHeader /> <TypebotHeader />
{workspace && <UsageAlertBanners workspace={workspace} />}
<Flex <Flex
h="full" h="full"
w="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 { UnlockPlanAlertInfo } from '@/components/UnlockPlanAlertInfo'
import { WorkspaceInvitation, WorkspaceRole } from '@typebot.io/prisma' import { WorkspaceInvitation, WorkspaceRole } from '@typebot.io/prisma'
import React from 'react' import React from 'react'
import { getSeatsLimit, isSeatsLimitReached } from '@typebot.io/lib/pricing'
import { AddMemberForm } from './AddMemberForm' import { AddMemberForm } from './AddMemberForm'
import { MemberItem } from './MemberItem' import { MemberItem } from './MemberItem'
import { isDefined } from '@typebot.io/lib' import { isDefined } from '@typebot.io/lib'
@@ -21,6 +20,7 @@ import { updateMemberQuery } from '../queries/updateMemberQuery'
import { Member } from '../types' import { Member } from '../types'
import { useWorkspace } from '../WorkspaceProvider' import { useWorkspace } from '../WorkspaceProvider'
import { useScopedI18n } from '@/locales' import { useScopedI18n } from '@/locales'
import { getSeatsLimit } from '@typebot.io/lib/billing/getSeatsLimit'
export const MembersList = () => { export const MembersList = () => {
const scopedT = useScopedI18n('workspace.membersList') const scopedT = useScopedI18n('workspace.membersList')
@@ -92,13 +92,9 @@ export const MembersList = () => {
const seatsLimit = workspace ? getSeatsLimit(workspace) : undefined const seatsLimit = workspace ? getSeatsLimit(workspace) : undefined
const canInviteNewMember = const canInviteNewMember = workspace
workspace && ? currentMembersCount < (seatsLimit as number)
!isSeatsLimitReached({ : false
plan: workspace?.plan,
customSeatsLimit: workspace?.customSeatsLimit,
existingMembersAndInvitationsCount: currentMembersCount,
})
return ( return (
<Stack w="full" spacing={3}> <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 const metadata = session.metadata as unknown as
| { | {
plan: 'STARTER' | 'PRO' plan: 'STARTER' | 'PRO'
additionalChats: string
workspaceId: string workspaceId: string
userId: string userId: string
} }
| { claimableCustomPlanId: string; userId: string } | { claimableCustomPlanId: string; userId: string }
if ('plan' in metadata) { if ('plan' in metadata) {
const { workspaceId, plan, additionalChats } = metadata const { workspaceId, plan } = metadata
if (!workspaceId || !plan || !additionalChats) if (!workspaceId || !plan)
return res return res
.status(500) .status(500)
.send({ message: `Couldn't retrieve valid metadata` }) .send({ message: `Couldn't retrieve valid metadata` })
const workspace = await prisma.workspace.update({ const workspace = await prisma.workspace.update({
where: { id: workspaceId }, where: { id: workspaceId },
data: { data: {
plan, plan,
stripeId: session.customer as string, stripeId: session.customer as string,
additionalChatsIndex: parseInt(additionalChats),
isQuarantined: false, isQuarantined: false,
}, },
include: { include: {
@@ -84,7 +83,6 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
userId: user.id, userId: user.id,
data: { data: {
plan, plan,
additionalChatsIndex: parseInt(additionalChats),
}, },
}, },
]) ])
@@ -119,7 +117,6 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
userId, userId,
data: { data: {
plan: Plan.CUSTOM, plan: Plan.CUSTOM,
additionalChatsIndex: 0,
}, },
}, },
]) ])
@@ -148,7 +145,6 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
}, },
data: { data: {
plan: Plan.FREE, plan: Plan.FREE,
additionalChatsIndex: 0,
customChatsLimit: null, customChatsLimit: null,
customStorageLimit: null, customStorageLimit: null,
customSeatsLimit: null, customSeatsLimit: null,
@@ -172,7 +168,6 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
userId: user.id, userId: user.id,
data: { data: {
plan: Plan.FREE, plan: Plan.FREE,
additionalChatsIndex: 0,
}, },
}, },
]) ])

View File

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

View File

@@ -18,7 +18,7 @@ const stripe = new Stripe(env.STRIPE_SECRET_KEY ?? '', {
export const addSubscriptionToWorkspace = async ( export const addSubscriptionToWorkspace = async (
workspaceId: string, workspaceId: string,
items: Stripe.SubscriptionCreateParams.Item[], items: Stripe.SubscriptionCreateParams.Item[],
metadata: Pick<Workspace, 'additionalChatsIndex' | 'plan'> metadata: Pick<Workspace, 'plan'>
) => { ) => {
const { id: stripeId } = await stripe.customers.create({ const { id: stripeId } = await stripe.customers.create({
email: 'test-user@gmail.com', 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> <details><summary><h4>Stripe</h4></summary>
<p> <p>
| Parameter | Default | Description | | Parameter | Default | Description |
| --------------------------------------- | ------- | ------------------------------------------- | | ----------------------------- | ------- | ----------------------------------------- |
| NEXT_PUBLIC_STRIPE_PUBLIC_KEY | | Stripe public key | | NEXT_PUBLIC_STRIPE_PUBLIC_KEY | | Stripe public key |
| STRIPE_SECRET_KEY | | Stripe secret key | | STRIPE_SECRET_KEY | | Stripe secret key |
| STRIPE_STARTER_PRODUCT_ID | | Starter plan product ID | | STRIPE_STARTER_PRICE_ID | | Starter plan price id |
| STRIPE_STARTER_MONTHLY_PRICE_ID | | Starter monthly plan price id | | STRIPE_PRO_PRICE_ID | | Pro monthly plan price id |
| STRIPE_STARTER_YEARLY_PRICE_ID | | Starter yearly plan price id | | STRIPE_STARTER_CHATS_PRICE_ID | | Starter Additional chats monthly price id |
| STRIPE_PRO_PRODUCT_ID | | Pro plan product ID | | STRIPE_PRO_CHATS_PRICE_ID | | Pro Additional chats monthly price id |
| STRIPE_PRO_MONTHLY_PRICE_ID | | Pro monthly plan price id | | STRIPE_WEBHOOK_SECRET | | Stripe Webhook secret |
| 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 |
</p></details> </p></details>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

18
packages/env/env.ts vendored
View File

@@ -140,20 +140,10 @@ const stripeEnv = {
server: { server: {
STRIPE_SECRET_KEY: z.string().min(1).optional(), STRIPE_SECRET_KEY: z.string().min(1).optional(),
STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(), STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(),
STRIPE_STARTER_PRODUCT_ID: z.string().min(1).optional(), STRIPE_STARTER_PRICE_ID: z.string().min(1).optional(),
STRIPE_STARTER_MONTHLY_PRICE_ID: z.string().min(1).optional(), STRIPE_STARTER_CHATS_PRICE_ID: z.string().min(1).optional(),
STRIPE_STARTER_YEARLY_PRICE_ID: z.string().min(1).optional(), STRIPE_PRO_PRICE_ID: z.string().min(1).optional(),
STRIPE_STARTER_CHATS_MONTHLY_PRICE_ID: z.string().min(1).optional(), STRIPE_PRO_CHATS_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(),
}, },
client: { client: {
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: z.string().min(1).optional(), 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", "@udecode/plate-common": "^21.1.5",
"got": "12.6.0", "got": "12.6.0",
"minio": "7.1.3", "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' import { z } from 'zod'
export const subscriptionSchema = z.object({ export const subscriptionSchema = z.object({
isYearly: z.boolean(), currentBillingPeriod: z.object({
start: z.date(),
end: z.date(),
}),
currency: z.enum(['eur', 'usd']), currency: z.enum(['eur', 'usd']),
cancelDate: z.date().optional(), cancelDate: z.date().optional(),
status: z.enum(['active', 'past_due']), status: z.enum(['active', 'past_due']),

View File

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

View File

@@ -4,25 +4,26 @@ import {
PrismaClient, PrismaClient,
WorkspaceRole, WorkspaceRole,
} from '@typebot.io/prisma' } from '@typebot.io/prisma'
import { isDefined } from '@typebot.io/lib' import { isDefined, isEmpty } from '@typebot.io/lib'
import { getChatsLimit } from '@typebot.io/lib/pricing' import { getChatsLimit } from '@typebot.io/lib/billing/getChatsLimit'
import { getUsage } from '@typebot.io/lib/api/getUsage' import { getUsage } from '@typebot.io/lib/api/getUsage'
import { promptAndSetEnvironment } from './utils' import { promptAndSetEnvironment } from './utils'
import { Workspace } from '@typebot.io/schemas' import { Workspace } from '@typebot.io/schemas'
import { sendAlmostReachedChatsLimitEmail } from '@typebot.io/emails/src/emails/AlmostReachedChatsLimitEmail' 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 { TelemetryEvent } from '@typebot.io/schemas/features/telemetry'
import { sendTelemetryEvents } from '@typebot.io/lib/telemetry/sendTelemetryEvent' import { sendTelemetryEvents } from '@typebot.io/lib/telemetry/sendTelemetryEvent'
import Stripe from 'stripe'
import { createId } from '@paralleldrive/cuid2'
const prisma = new PrismaClient() const prisma = new PrismaClient()
const LIMIT_EMAIL_TRIGGER_PERCENT = 0.8 const LIMIT_EMAIL_TRIGGER_PERCENT = 0.75
type WorkspaceForDigest = Pick< type WorkspaceForDigest = Pick<
Workspace, Workspace,
| 'id' | 'id'
| 'plan' | 'plan'
| 'name'
| 'customChatsLimit' | 'customChatsLimit'
| 'additionalChatsIndex'
| 'isQuarantined' | 'isQuarantined'
| 'chatsLimitFirstEmailSentAt' | 'chatsLimitFirstEmailSentAt'
| 'chatsLimitSecondEmailSentAt' | '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') await promptAndSetEnvironment('production')
console.log('Get collected results from the last hour...') 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({ const results = await prisma.result.groupBy({
by: ['typebotId'], by: ['typebotId'],
@@ -47,6 +76,7 @@ export const sendTotalResultsDigest = async () => {
where: { where: {
hasStarted: true, hasStarted: true,
createdAt: { createdAt: {
lt: zeroedMinutesHour,
gte: hourAgo, gte: hourAgo,
}, },
}, },
@@ -69,11 +99,11 @@ export const sendTotalResultsDigest = async () => {
}, },
select: { select: {
id: true, id: true,
name: true,
typebots: { select: { id: true } }, typebots: { select: { id: true } },
members: { members: {
select: { user: { select: { id: true, email: true } }, role: true }, select: { user: { select: { id: true, email: true } }, role: true },
}, },
additionalChatsIndex: true,
additionalStorageIndex: true, additionalStorageIndex: true,
customChatsLimit: true, customChatsLimit: true,
customStorageLimit: true, customStorageLimit: true,
@@ -81,6 +111,7 @@ export const sendTotalResultsDigest = async () => {
isQuarantined: true, isQuarantined: true,
chatsLimitFirstEmailSentAt: true, chatsLimitFirstEmailSentAt: true,
chatsLimitSecondEmailSentAt: true, chatsLimitSecondEmailSentAt: true,
stripeId: true,
}, },
}) })
@@ -110,9 +141,27 @@ export const sendTotalResultsDigest = async () => {
.map((result) => result.workspace) .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 ( const sendAlertIfLimitReached = async (
@@ -144,7 +193,7 @@ const sendAlertIfLimitReached = async (
to, to,
usagePercent: Math.round((totalChatsUsed / chatsLimit) * 100), usagePercent: Math.round((totalChatsUsed / chatsLimit) * 100),
chatsLimit, chatsLimit,
url: `https://app.typebot.io/typebots?workspaceId=${workspace.id}`, workspaceName: workspace.name,
}) })
await prisma.workspace.updateMany({ await prisma.workspace.updateMany({
where: { id: workspace.id }, where: { id: workspace.id },
@@ -155,32 +204,7 @@ const sendAlertIfLimitReached = async (
} }
} }
if ( if (totalChatsUsed > chatsLimit * 1.5 && workspace.plan === Plan.FREE) {
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) {
console.log(`Automatically quarantine workspace ${workspace.id}...`) console.log(`Automatically quarantine workspace ${workspace.id}...`)
await prisma.workspace.updateMany({ await prisma.workspace.updateMany({
where: { id: workspace.id }, where: { id: workspace.id },
@@ -207,4 +231,67 @@ const sendAlertIfLimitReached = async (
return events 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 }, chatsLimitFirstEmailSentAt: { not: null },
}, },
{
storageLimitFirstEmailSentAt: { not: null },
},
], ],
}, },
data: { data: {
isQuarantined: false, isQuarantined: false,
chatsLimitFirstEmailSentAt: null, chatsLimitFirstEmailSentAt: null,
storageLimitFirstEmailSentAt: null,
chatsLimitSecondEmailSentAt: null, chatsLimitSecondEmailSentAt: null,
storageLimitSecondEmailSentAt: null,
}, },
}) })
console.log(`Resetted ${count} workspaces.`) 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 } }, user: { email: { not: response.email } },
}, },
}, },
additionalChatsIndex: true,
additionalStorageIndex: true, additionalStorageIndex: true,
typebots: { typebots: {
orderBy: { orderBy: {
@@ -82,10 +81,6 @@ const inspectUser = async () => {
console.log(' - Name:', workspace.workspace.name) console.log(' - Name:', workspace.workspace.name)
console.log(' Plan:', workspace.workspace.plan) console.log(' Plan:', workspace.workspace.plan)
console.log(' Members:', workspace.workspace.members.length + 1) console.log(' Members:', workspace.workspace.members.length + 1)
console.log(
' Additional chats:',
workspace.workspace.additionalChatsIndex
)
console.log( console.log(
' Additional storage:', ' Additional storage:',
workspace.workspace.additionalStorageIndex 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:bulkUpdate": "tsx bulkUpdate.ts",
"db:fixTypebots": "tsx fixTypebots.ts", "db:fixTypebots": "tsx fixTypebots.ts",
"telemetry:sendTotalResultsDigest": "tsx sendTotalResultsDigest.ts", "telemetry:sendTotalResultsDigest": "tsx sendTotalResultsDigest.ts",
"sendAlertEmails": "tsx sendAlertEmails.ts", "checkAndReportChatsUsage": "tsx checkAndReportChatsUsage.ts",
"inspectUser": "tsx inspectUser.ts", "inspectUser": "tsx inspectUser.ts",
"checkSubscriptionsStatus": "tsx checkSubscriptionsStatus.ts" "checkSubscriptionsStatus": "tsx checkSubscriptionsStatus.ts",
"createChatsPrices": "tsx createChatsPrices.ts",
"migrateSubscriptionsToUsageBased": "tsx migrateSubscriptionsToUsageBased.ts"
}, },
"devDependencies": { "devDependencies": {
"@typebot.io/emails": "workspace:*", "@typebot.io/emails": "workspace:*",
@@ -31,5 +33,8 @@
"tsx": "3.12.7", "tsx": "3.12.7",
"typescript": "5.1.6", "typescript": "5.1.6",
"zod": "3.21.4" "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: remark-slate:
specifier: ^1.8.6 specifier: ^1.8.6
version: 1.8.6 version: 1.8.6
stripe:
specifier: 12.13.0
version: 12.13.0
devDependencies: devDependencies:
'@paralleldrive/cuid2': '@paralleldrive/cuid2':
specifier: 2.2.1 specifier: 2.2.1
@@ -1277,6 +1280,10 @@ importers:
version: 5.1.6 version: 5.1.6
packages/scripts: packages/scripts:
dependencies:
'@paralleldrive/cuid2':
specifier: 2.2.1
version: 2.2.1
devDependencies: devDependencies:
'@typebot.io/emails': '@typebot.io/emails':
specifier: workspace:* specifier: workspace:*