⚡ (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:
@@ -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
|
||||||
24
.github/workflows/send-total-results-digest.yml
vendored
24
.github/workflows/send-total-results-digest.yml
vendored
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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])
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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't provide an answer after
|
<Text>
|
||||||
~48h, your bots will be closed for the remaining of the month. For a
|
That'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'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'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'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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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's amazing. 💙</Text>
|
<Text>Your bots are chatting a lot. That's amazing. 💙</Text>
|
||||||
<Text>
|
<Text>
|
||||||
This means you'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'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
18
packages/env/env.ts
vendored
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
167
packages/lib/billing/constants.ts
Normal file
167
packages/lib/billing/constants.ts
Normal 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[]
|
||||||
21
packages/lib/billing/formatPrice.ts
Normal file
21
packages/lib/billing/formatPrice.ts
Normal 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)
|
||||||
|
}
|
||||||
19
packages/lib/billing/getChatsLimit.ts
Normal file
19
packages/lib/billing/getChatsLimit.ts
Normal 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]
|
||||||
|
}
|
||||||
12
packages/lib/billing/getSeatsLimit.ts
Normal file
12
packages/lib/billing/getSeatsLimit.ts
Normal 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]
|
||||||
|
}
|
||||||
56
packages/lib/billing/guessIfUserIsEuropean.ts
Normal file
56
packages/lib/billing/guessIfUserIsEuropean.ts
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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']),
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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.`)
|
||||||
|
|||||||
50
packages/scripts/createChatsPrices.ts
Normal file
50
packages/scripts/createChatsPrices.ts
Normal 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()
|
||||||
@@ -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
|
||||||
|
|||||||
278
packages/scripts/migrateSubscriptionsToUsageBased.ts
Normal file
278
packages/scripts/migrateSubscriptionsToUsageBased.ts
Normal 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()
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
7
pnpm-lock.yaml
generated
@@ -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:*
|
||||||
|
|||||||
Reference in New Issue
Block a user