diff --git a/apps/builder/src/features/billing/api/createCheckoutSession.ts b/apps/builder/src/features/billing/api/createCheckoutSession.ts index ec466b1c0bf..1e55cc90d5d 100644 --- a/apps/builder/src/features/billing/api/createCheckoutSession.ts +++ b/apps/builder/src/features/billing/api/createCheckoutSession.ts @@ -4,7 +4,6 @@ import { TRPCError } from '@trpc/server' import { Plan } from '@typebot.io/prisma' import Stripe from 'stripe' import { z } from 'zod' -import { parseSubscriptionItems } from '../helpers/parseSubscriptionItems' import { isAdminWriteWorkspaceForbidden } from '@/features/workspace/helpers/isAdminWriteWorkspaceForbidden' import { env } from '@typebot.io/env' @@ -26,14 +25,12 @@ export const createCheckoutSession = authenticatedProcedure currency: z.enum(['usd', 'eur']), plan: z.enum([Plan.STARTER, Plan.PRO]), returnUrl: z.string(), - additionalChats: z.number(), vat: z .object({ type: z.string(), value: z.string(), }) .optional(), - isYearly: z.boolean(), }) ) .output( @@ -43,17 +40,7 @@ export const createCheckoutSession = authenticatedProcedure ) .mutation( async ({ - input: { - vat, - email, - company, - workspaceId, - currency, - plan, - returnUrl, - additionalChats, - isYearly, - }, + input: { vat, email, company, workspaceId, currency, plan, returnUrl }, ctx: { user }, }) => { if (!env.STRIPE_SECRET_KEY) @@ -116,8 +103,6 @@ export const createCheckoutSession = authenticatedProcedure currency, plan, returnUrl, - additionalChats, - isYearly, }) if (!checkoutUrl) @@ -138,22 +123,12 @@ type Props = { currency: 'usd' | 'eur' plan: 'STARTER' | 'PRO' returnUrl: string - additionalChats: number - isYearly: boolean userId: string } export const createCheckoutSessionUrl = (stripe: Stripe) => - async ({ - customerId, - workspaceId, - currency, - plan, - returnUrl, - additionalChats, - isYearly, - }: Props) => { + async ({ customerId, workspaceId, currency, plan, returnUrl }: Props) => { const session = await stripe.checkout.sessions.create({ success_url: `${returnUrl}?stripe=${plan}&success=true`, cancel_url: `${returnUrl}?stripe=cancel`, @@ -167,12 +142,25 @@ export const createCheckoutSessionUrl = metadata: { workspaceId, plan, - additionalChats, }, currency, billing_address_collection: 'required', automatic_tax: { enabled: true }, - line_items: parseSubscriptionItems(plan, additionalChats, isYearly), + line_items: [ + { + price: + plan === 'STARTER' + ? env.STRIPE_STARTER_PRICE_ID + : env.STRIPE_PRO_PRICE_ID, + quantity: 1, + }, + { + price: + plan === 'STARTER' + ? env.STRIPE_STARTER_CHATS_PRICE_ID + : env.STRIPE_PRO_CHATS_PRICE_ID, + }, + ], }) return session.url diff --git a/apps/builder/src/features/billing/api/getSubscription.ts b/apps/builder/src/features/billing/api/getSubscription.ts index 582be1cde5f..2c3a47c3dcf 100644 --- a/apps/builder/src/features/billing/api/getSubscription.ts +++ b/apps/builder/src/features/billing/api/getSubscription.ts @@ -5,7 +5,6 @@ import Stripe from 'stripe' import { z } from 'zod' import { subscriptionSchema } from '@typebot.io/schemas/features/billing/subscription' import { isReadWorkspaceFobidden } from '@/features/workspace/helpers/isReadWorkspaceFobidden' -import { priceIds } from '@typebot.io/lib/api/pricing' import { env } from '@typebot.io/env' export const getSubscription = authenticatedProcedure @@ -75,15 +74,14 @@ export const getSubscription = authenticatedProcedure return { subscription: { + currentBillingPeriod: + subscriptionSchema.shape.currentBillingPeriod.parse({ + start: new Date(currentSubscription.current_period_start), + end: new Date(currentSubscription.current_period_end), + }), status: subscriptionSchema.shape.status.parse( currentSubscription.status ), - isYearly: currentSubscription.items.data.some((item) => { - return ( - priceIds.STARTER.chats.yearly === item.price.id || - priceIds.PRO.chats.yearly === item.price.id - ) - }), currency: currentSubscription.currency as 'usd' | 'eur', cancelDate: currentSubscription.cancel_at ? new Date(currentSubscription.cancel_at * 1000) @@ -91,8 +89,3 @@ export const getSubscription = authenticatedProcedure }, } }) - -export const chatPriceIds = [priceIds.STARTER.chats.monthly] - .concat(priceIds.STARTER.chats.yearly) - .concat(priceIds.PRO.chats.monthly) - .concat(priceIds.PRO.chats.yearly) diff --git a/apps/builder/src/features/billing/api/getUsage.ts b/apps/builder/src/features/billing/api/getUsage.ts index 56cb51ecb75..ec1a72d6346 100644 --- a/apps/builder/src/features/billing/api/getUsage.ts +++ b/apps/builder/src/features/billing/api/getUsage.ts @@ -3,6 +3,8 @@ import { authenticatedProcedure } from '@/helpers/server/trpc' import { TRPCError } from '@trpc/server' import { z } from 'zod' import { isReadWorkspaceFobidden } from '@/features/workspace/helpers/isReadWorkspaceFobidden' +import { env } from '@typebot.io/env' +import Stripe from 'stripe' export const getUsage = authenticatedProcedure .meta({ @@ -19,13 +21,15 @@ export const getUsage = authenticatedProcedure workspaceId: z.string(), }) ) - .output(z.object({ totalChatsUsed: z.number() })) + .output(z.object({ totalChatsUsed: z.number(), resetsAt: z.date() })) .query(async ({ input: { workspaceId }, ctx: { user } }) => { const workspace = await prisma.workspace.findFirst({ where: { id: workspaceId, }, select: { + stripeId: true, + plan: true, members: { select: { userId: true, @@ -42,19 +46,63 @@ export const getUsage = authenticatedProcedure message: 'Workspace not found', }) - const now = new Date() - const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1) + if ( + !env.STRIPE_SECRET_KEY || + !workspace.stripeId || + (workspace.plan !== 'STARTER' && workspace.plan !== 'PRO') + ) { + const now = new Date() + const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1) + + const totalChatsUsed = await prisma.result.count({ + where: { + typebotId: { in: workspace.typebots.map((typebot) => typebot.id) }, + hasStarted: true, + createdAt: { + gte: firstDayOfMonth, + }, + }, + }) + + const firstDayOfNextMonth = new Date( + firstDayOfMonth.getFullYear(), + firstDayOfMonth.getMonth() + 1, + 1 + ) + return { totalChatsUsed, resetsAt: firstDayOfNextMonth } + } + + const stripe = new Stripe(env.STRIPE_SECRET_KEY, { + apiVersion: '2022-11-15', + }) + + const subscriptions = await stripe.subscriptions.list({ + customer: workspace.stripeId, + }) + + const currentSubscription = subscriptions.data + .filter((sub) => ['past_due', 'active'].includes(sub.status)) + .sort((a, b) => a.created - b.created) + .shift() + + if (!currentSubscription) + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `No subscription found on workspace: ${workspaceId}`, + }) + const totalChatsUsed = await prisma.result.count({ where: { typebotId: { in: workspace.typebots.map((typebot) => typebot.id) }, hasStarted: true, createdAt: { - gte: firstDayOfMonth, + gte: new Date(currentSubscription.current_period_start * 1000), }, }, }) return { totalChatsUsed, + resetsAt: new Date(currentSubscription.current_period_end * 1000), } }) diff --git a/apps/builder/src/features/billing/api/listInvoices.ts b/apps/builder/src/features/billing/api/listInvoices.ts index 40f072b2f58..cccd5939dfc 100644 --- a/apps/builder/src/features/billing/api/listInvoices.ts +++ b/apps/builder/src/features/billing/api/listInvoices.ts @@ -64,12 +64,12 @@ export const listInvoices = authenticatedProcedure .filter( (invoice) => isDefined(invoice.invoice_pdf) && isDefined(invoice.id) ) - .map((i) => ({ - id: i.number as string, - url: i.invoice_pdf as string, - amount: i.subtotal, - currency: i.currency, - date: i.status_transitions.paid_at, + .map((invoice) => ({ + id: invoice.number as string, + url: invoice.invoice_pdf as string, + amount: invoice.subtotal, + currency: invoice.currency, + date: invoice.status_transitions.paid_at, })), } }) diff --git a/apps/builder/src/features/billing/api/updateSubscription.ts b/apps/builder/src/features/billing/api/updateSubscription.ts index fe31eba07f0..b37b5b4b7a3 100644 --- a/apps/builder/src/features/billing/api/updateSubscription.ts +++ b/apps/builder/src/features/billing/api/updateSubscription.ts @@ -5,15 +5,10 @@ import { TRPCError } from '@trpc/server' import { Plan } from '@typebot.io/prisma' import { workspaceSchema } from '@typebot.io/schemas' import Stripe from 'stripe' -import { isDefined } from '@typebot.io/lib' import { z } from 'zod' -import { getChatsLimit } from '@typebot.io/lib/pricing' -import { chatPriceIds } from './getSubscription' import { createCheckoutSessionUrl } from './createCheckoutSession' import { isAdminWriteWorkspaceForbidden } from '@/features/workspace/helpers/isAdminWriteWorkspaceForbidden' -import { getUsage } from '@typebot.io/lib/api/getUsage' import { env } from '@typebot.io/env' -import { priceIds } from '@typebot.io/lib/api/pricing' export const updateSubscription = authenticatedProcedure .meta({ @@ -30,9 +25,7 @@ export const updateSubscription = authenticatedProcedure returnUrl: z.string(), workspaceId: z.string(), plan: z.enum([Plan.STARTER, Plan.PRO]), - additionalChats: z.number(), currency: z.enum(['usd', 'eur']), - isYearly: z.boolean(), }) ) .output( @@ -43,14 +36,7 @@ export const updateSubscription = authenticatedProcedure ) .mutation( async ({ - input: { - workspaceId, - plan, - additionalChats, - currency, - isYearly, - returnUrl, - }, + input: { workspaceId, plan, currency, returnUrl }, ctx: { user }, }) => { if (!env.STRIPE_SECRET_KEY) @@ -81,6 +67,7 @@ export const updateSubscription = authenticatedProcedure code: 'NOT_FOUND', message: 'Workspace not found', }) + const stripe = new Stripe(env.STRIPE_SECRET_KEY, { apiVersion: '2022-11-15', }) @@ -91,39 +78,57 @@ export const updateSubscription = authenticatedProcedure }) const subscription = data[0] as Stripe.Subscription | undefined const currentPlanItemId = subscription?.items.data.find((item) => - [env.STRIPE_STARTER_PRODUCT_ID, env.STRIPE_PRO_PRODUCT_ID].includes( - item.price.product.toString() + [env.STRIPE_STARTER_PRICE_ID, env.STRIPE_PRO_PRICE_ID].includes( + item.price.id ) )?.id - const currentAdditionalChatsItemId = subscription?.items.data.find( - (item) => chatPriceIds.includes(item.price.id) + const currentUsageItemId = subscription?.items.data.find( + (item) => + item.price.id === env.STRIPE_STARTER_CHATS_PRICE_ID || + item.price.id === env.STRIPE_PRO_CHATS_PRICE_ID )?.id - const frequency = isYearly ? 'yearly' : 'monthly' const items = [ { id: currentPlanItemId, - price: priceIds[plan].base[frequency], + price: + plan === Plan.STARTER + ? env.STRIPE_STARTER_PRICE_ID + : env.STRIPE_PRO_PRICE_ID, quantity: 1, }, - additionalChats === 0 && !currentAdditionalChatsItemId - ? undefined - : { - id: currentAdditionalChatsItemId, - price: priceIds[plan].chats[frequency], - quantity: getChatsLimit({ - plan, - additionalChatsIndex: additionalChats, - customChatsLimit: null, - }), - deleted: subscription ? additionalChats === 0 : undefined, - }, - ].filter(isDefined) + { + id: currentUsageItemId, + price: + plan === Plan.STARTER + ? env.STRIPE_STARTER_CHATS_PRICE_ID + : env.STRIPE_PRO_CHATS_PRICE_ID, + }, + ] if (subscription) { + if (plan === 'STARTER') { + const totalChatsUsed = await prisma.result.count({ + where: { + typebot: { workspaceId }, + hasStarted: true, + createdAt: { + gte: new Date(subscription.current_period_start * 1000), + }, + }, + }) + if (totalChatsUsed >= 4000) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + "You have collected more than 4000 chats during this billing cycle. You can't downgrade to the Starter.", + }) + } + } await stripe.subscriptions.update(subscription.id, { items, - proration_behavior: 'always_invoice', + proration_behavior: + plan === 'PRO' ? 'always_invoice' : 'create_prorations', }) } else { const checkoutUrl = await createCheckoutSessionUrl(stripe)({ @@ -133,31 +138,16 @@ export const updateSubscription = authenticatedProcedure currency, plan, returnUrl, - additionalChats, - isYearly, }) return { checkoutUrl } } - let isQuarantined = workspace.isQuarantined - - if (isQuarantined) { - const newChatsLimit = getChatsLimit({ - plan, - additionalChatsIndex: additionalChats, - customChatsLimit: null, - }) - const { totalChatsUsed } = await getUsage(prisma)(workspaceId) - if (totalChatsUsed < newChatsLimit) isQuarantined = false - } - const updatedWorkspace = await prisma.workspace.update({ where: { id: workspaceId }, data: { plan, - additionalChatsIndex: additionalChats, - isQuarantined, + isQuarantined: false, }, }) @@ -168,7 +158,6 @@ export const updateSubscription = authenticatedProcedure userId: user.id, data: { plan, - additionalChatsIndex: additionalChats, }, }, ]) diff --git a/apps/builder/src/features/billing/billing.spec.ts b/apps/builder/src/features/billing/billing.spec.ts index 6dcaf03938e..a703f3f531c 100644 --- a/apps/builder/src/features/billing/billing.spec.ts +++ b/apps/builder/src/features/billing/billing.spec.ts @@ -116,29 +116,25 @@ test('plan changes should work', async ({ page }) => { await page.click('text=Plan Change Workspace') await page.click('text=Settings & Members') await page.click('text=Billing & Usage') - await page.click('button >> text="2,000"') - await page.click('button >> text="3,500"') - await page.click('button >> text="2"') - await page.click('button >> text="4"') - await page.locator('label span').first().click() - await expect(page.locator('text="$73"')).toBeVisible() + await expect(page.locator('text="$39"')).toBeVisible() await page.click('button >> text=Upgrade >> nth=0') await page.getByLabel('Company name').fill('Company LLC') await page.getByRole('button', { name: 'Go to checkout' }).click() await page.waitForNavigation() expect(page.url()).toContain('https://checkout.stripe.com') - await expect(page.locator('text=$73.00 >> nth=0')).toBeVisible() - await expect(page.locator('text=$30.00 >> nth=0')).toBeVisible() - await expect(page.locator('text=$4.00 >> nth=0')).toBeVisible() + await expect(page.locator('text=$39 >> nth=0')).toBeVisible() const stripeId = await addSubscriptionToWorkspace( planChangeWorkspaceId, [ { - price: env.STRIPE_STARTER_MONTHLY_PRICE_ID, + price: env.STRIPE_STARTER_PRICE_ID, quantity: 1, }, + { + price: env.STRIPE_STARTER_CHATS_PRICE_ID, + }, ], - { plan: Plan.STARTER, additionalChatsIndex: 0 } + { plan: Plan.STARTER } ) // Update plan with additional quotas @@ -147,30 +143,9 @@ test('plan changes should work', async ({ page }) => { await page.click('text=Billing & Usage') await expect(page.locator('text="/ 2,000"')).toBeVisible() await expect(page.getByText('/ 2,000')).toBeVisible() - await page.click('button >> text="2,000"') - await page.click('button >> text="3,500"') - await page.click('button >> text="2"') - await page.click('button >> text="4"') - await expect(page.locator('text="$73"')).toBeVisible() - await page.click('button >> text=Update') - await expect( - page.locator( - 'text="Workspace STARTER plan successfully updated 🎉" >> nth=0' - ) - ).toBeVisible() - await page.click('text="Members"') - await page.click('text="Billing & Usage"') - await expect(page.locator('text="$73"')).toBeVisible() - await expect(page.locator('text="/ 3,500"')).toBeVisible() - await expect(page.getByRole('button', { name: '3,500' })).toBeVisible() - await expect(page.getByRole('button', { name: '4' })).toBeVisible() // Upgrade to PRO - await page.click('button >> text="10,000"') - await page.click('button >> text="25,000"') - await page.click('button >> text="10"') - await page.click('button >> text="15"') - await expect(page.locator('text="$247"')).toBeVisible() + await expect(page.locator('text="$89"')).toBeVisible() await page.click('button >> text=Upgrade') await expect( page.locator('text="Workspace PRO plan successfully updated 🎉" >> nth=0') @@ -181,11 +156,12 @@ test('plan changes should work', async ({ page }) => { page.waitForNavigation(), page.click('text="Billing portal"'), ]) - await expect(page.getByText('$247.00 per month')).toBeVisible({ + await expect(page.getByText('$39.00')).toBeVisible({ + timeout: 10000, + }) + await expect(page.getByText('$50.00')).toBeVisible({ timeout: 10000, }) - await expect(page.getByText('(×25000)')).toBeVisible() - await expect(page.getByText('(×15)')).toBeVisible() await expect(page.locator('text="Add payment method"')).toBeVisible() await cancelSubscription(stripeId) @@ -212,9 +188,8 @@ test('should display invoices', async ({ page }) => { await page.click('text=Billing & Usage') await expect(page.locator('text="Invoices"')).toBeVisible() await expect(page.locator('tr')).toHaveCount(4) - await expect(page.locator('text="$39.00"')).toBeVisible() - await expect(page.locator('text="$34.00"')).toBeVisible() - await expect(page.locator('text="$174.00"')).toBeVisible() + await expect(page.getByText('$39.00')).toBeVisible() + await expect(page.getByText('$50.00')).toBeVisible() }) test('custom plans should work', async ({ page }) => { diff --git a/apps/builder/src/features/billing/components/ChangePlanForm.tsx b/apps/builder/src/features/billing/components/ChangePlanForm.tsx index 5a1f8858613..140439bebd2 100644 --- a/apps/builder/src/features/billing/components/ChangePlanForm.tsx +++ b/apps/builder/src/features/billing/components/ChangePlanForm.tsx @@ -1,9 +1,8 @@ -import { Stack, HStack, Text, Switch, Tag } from '@chakra-ui/react' +import { Stack, HStack, Text } from '@chakra-ui/react' import { Plan } from '@typebot.io/prisma' import { TextLink } from '@/components/TextLink' import { useToast } from '@/hooks/useToast' import { trpc } from '@/lib/trpc' -import { guessIfUserIsEuropean } from '@typebot.io/lib/pricing' import { Workspace } from '@typebot.io/schemas' import { PreCheckoutModal, PreCheckoutModalProps } from './PreCheckoutModal' import { useState } from 'react' @@ -13,6 +12,7 @@ import { StarterPlanPricingCard } from './StarterPlanPricingCard' import { ProPlanPricingCard } from './ProPlanPricingCard' import { useScopedI18n } from '@/locales' import { StripeClimateLogo } from './StripeClimateLogo' +import { guessIfUserIsEuropean } from '@typebot.io/lib/billing/guessIfUserIsEuropean' type Props = { workspace: Workspace @@ -26,21 +26,12 @@ export const ChangePlanForm = ({ workspace, excludedPlans }: Props) => { const { showToast } = useToast() const [preCheckoutPlan, setPreCheckoutPlan] = useState() - const [isYearly, setIsYearly] = useState(true) const trpcContext = trpc.useContext() - const { data, refetch } = trpc.billing.getSubscription.useQuery( - { - workspaceId: workspace.id, - }, - { - onSuccess: ({ subscription }) => { - if (isYearly === false) return - setIsYearly(subscription?.isYearly ?? true) - }, - } - ) + const { data, refetch } = trpc.billing.getSubscription.useQuery({ + workspaceId: workspace.id, + }) const { mutate: updateSubscription, isLoading: isUpdatingSubscription } = trpc.billing.updateSubscription.useMutation({ @@ -65,23 +56,15 @@ export const ChangePlanForm = ({ workspace, excludedPlans }: Props) => { }, }) - const handlePayClick = async ({ - plan, - selectedChatsLimitIndex, - }: { - plan: 'STARTER' | 'PRO' - selectedChatsLimitIndex: number - }) => { - if (!user || selectedChatsLimitIndex === undefined) return + const handlePayClick = async (plan: 'STARTER' | 'PRO') => { + if (!user) return const newSubscription = { plan, workspaceId: workspace.id, - additionalChats: selectedChatsLimitIndex, currency: data?.subscription?.currency ?? (guessIfUserIsEuropean() ? 'eur' : 'usd'), - isYearly, } as const if (workspace.stripeId) { updateSubscription({ @@ -122,26 +105,11 @@ export const ChangePlanForm = ({ workspace, excludedPlans }: Props) => { )} {data && ( - - Monthly - setIsYearly(!isYearly)} - /> - - Yearly - 16% off - - {excludedPlans?.includes('STARTER') ? null : ( - handlePayClick({ ...props, plan: Plan.STARTER }) - } - isYearly={isYearly} + currentPlan={workspace.plan} + onPayClick={() => handlePayClick(Plan.STARTER)} isLoading={isUpdatingSubscription} currency={data.subscription?.currency} /> @@ -149,12 +117,8 @@ export const ChangePlanForm = ({ workspace, excludedPlans }: Props) => { {excludedPlans?.includes('PRO') ? null : ( - handlePayClick({ ...props, plan: Plan.PRO }) - } - isYearly={isYearly} + currentPlan={workspace.plan} + onPayClick={() => handlePayClick(Plan.PRO)} isLoading={isUpdatingSubscription} currency={data.subscription?.currency} /> diff --git a/apps/builder/src/features/billing/components/ChatsProTiersModal.tsx b/apps/builder/src/features/billing/components/ChatsProTiersModal.tsx new file mode 100644 index 00000000000..af7b210456a --- /dev/null +++ b/apps/builder/src/features/billing/components/ChatsProTiersModal.tsx @@ -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 ( + + + + + Chats pricing table + + + + + + + + + + + + + + {proChatTiers.map((tier, index) => { + const pricePerMonth = + proChatTiers + .slice(0, index + 1) + .reduce( + (acc, slicedTier) => + acc + (slicedTier.flat_amount ?? 0), + 0 + ) / 100 + return ( + + + + + + ) + })} + +
Max chatsPrice per monthPrice per 1k chats
+ {tier.up_to === 'inf' + ? '2,000,000+' + : tier.up_to.toLocaleString()} + + {index === 0 ? 'included' : formatPrice(pricePerMonth)} + + {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 } + )} +
+
+
+ +
+
+ ) +} diff --git a/apps/builder/src/features/billing/components/FeaturesList.tsx b/apps/builder/src/features/billing/components/FeaturesList.tsx index 991fc40d19c..11e2015d714 100644 --- a/apps/builder/src/features/billing/components/FeaturesList.tsx +++ b/apps/builder/src/features/billing/components/FeaturesList.tsx @@ -12,8 +12,8 @@ type FeaturesListProps = { features: (string | JSX.Element)[] } & ListProps export const FeaturesList = ({ features, ...props }: FeaturesListProps) => ( {features.map((feat, idx) => ( - - + + {feat} ))} diff --git a/apps/builder/src/features/billing/components/PreCheckoutModal.tsx b/apps/builder/src/features/billing/components/PreCheckoutModal.tsx index 4332ad41f76..5bc31894c9c 100644 --- a/apps/builder/src/features/billing/components/PreCheckoutModal.tsx +++ b/apps/builder/src/features/billing/components/PreCheckoutModal.tsx @@ -25,9 +25,7 @@ export type PreCheckoutModalProps = { | { plan: 'STARTER' | 'PRO' workspaceId: string - additionalChats: number currency: 'eur' | 'usd' - isYearly: boolean } | undefined existingCompany?: string diff --git a/apps/builder/src/features/billing/components/ProPlanPricingCard.tsx b/apps/builder/src/features/billing/components/ProPlanPricingCard.tsx index 1a1516f2b4a..4b2a0f5bcbc 100644 --- a/apps/builder/src/features/billing/components/ProPlanPricingCard.tsx +++ b/apps/builder/src/features/billing/components/ProPlanPricingCard.tsx @@ -3,226 +3,151 @@ import { Heading, chakra, HStack, - Menu, - MenuButton, Button, - MenuList, - MenuItem, Text, Tooltip, Flex, Tag, useColorModeValue, + useDisclosure, } from '@chakra-ui/react' -import { ChevronLeftIcon } from '@/components/icons' import { Plan } from '@typebot.io/prisma' -import { useEffect, useState } from 'react' -import { isDefined, parseNumberWithCommas } from '@typebot.io/lib' -import { - chatsLimit, - computePrice, - formatPrice, - getChatsLimit, -} from '@typebot.io/lib/pricing' import { FeaturesList } from './FeaturesList' import { MoreInfoTooltip } from '@/components/MoreInfoTooltip' import { useI18n, useScopedI18n } from '@/locales' -import { Workspace } from '@typebot.io/schemas' +import { formatPrice } from '@typebot.io/lib/billing/formatPrice' +import { ChatsProTiersModal } from './ChatsProTiersModal' type Props = { - workspace: Pick< - Workspace, - | 'additionalChatsIndex' - | 'plan' - | 'customChatsLimit' - | 'customStorageLimit' - | 'stripeId' - > - currentSubscription: { - isYearly?: boolean - } + currentPlan: Plan currency?: 'usd' | 'eur' isLoading: boolean - isYearly: boolean - onPayClick: (props: { selectedChatsLimitIndex: number }) => void + onPayClick: () => void } export const ProPlanPricingCard = ({ - workspace, - currentSubscription, + currentPlan, currency, isLoading, - isYearly, onPayClick, }: Props) => { const t = useI18n() const scopedT = useScopedI18n('billing.pricingCard') - const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] = - useState() - - useEffect(() => { - if (isDefined(selectedChatsLimitIndex)) return - if (workspace.plan !== Plan.PRO) { - setSelectedChatsLimitIndex(0) - return - } - setSelectedChatsLimitIndex(workspace.additionalChatsIndex ?? 0) - }, [selectedChatsLimitIndex, workspace.additionalChatsIndex, workspace.plan]) - - const workspaceChatsLimit = workspace ? getChatsLimit(workspace) : undefined - - const isCurrentPlan = - chatsLimit[Plan.PRO].graduatedPrice[selectedChatsLimitIndex ?? 0] - .totalIncluded === workspaceChatsLimit && - isYearly === currentSubscription?.isYearly + const { isOpen, onOpen, onClose } = useDisclosure() const getButtonLabel = () => { - if (selectedChatsLimitIndex === undefined) return '' - if (workspace?.plan === Plan.PRO) { - if (isCurrentPlan) return scopedT('upgradeButton.current') - - if (selectedChatsLimitIndex !== workspace.additionalChatsIndex) - return t('update') - } + if (currentPlan === Plan.PRO) return scopedT('upgradeButton.current') return t('upgrade') } - const handlePayClick = async () => { - if (selectedChatsLimitIndex === undefined) return - onPayClick({ - selectedChatsLimitIndex, - }) - } - - const price = - computePrice( - Plan.PRO, - selectedChatsLimitIndex ?? 0, - isYearly ? 'yearly' : 'monthly' - ) ?? NaN - return ( - - - - {scopedT('pro.mostPopularLabel')} - - - - - - {scopedT('heading', { - plan: ( - - Pro - - ), - })} - - {scopedT('pro.description')} - - - - {formatPrice(price, currency)} - {scopedT('perMonth')} - - - - } - hasArrow - placement="top" - > - - {scopedT('pro.everythingFromStarter')} - - - {scopedT('plus')} - - - - - } - size="sm" - isLoading={selectedChatsLimitIndex === undefined} - > - {selectedChatsLimitIndex !== undefined - ? parseNumberWithCommas( - chatsLimit.PRO.graduatedPrice[ - selectedChatsLimitIndex - ].totalIncluded - ) - : undefined} - - - {chatsLimit.PRO.graduatedPrice.map((price, index) => ( - setSelectedChatsLimitIndex(index)} - > - {parseNumberWithCommas(price.totalIncluded)} - - ))} - - {' '} - {scopedT('chatsPerMonth')} - - {scopedT('chatsTooltip')} -
, - scopedT('pro.whatsAppIntegration'), - scopedT('pro.customDomains'), - scopedT('pro.analytics'), - ]} - /> - - {isYearly && workspace.stripeId && !isCurrentPlan && ( - - You pay {formatPrice(price * 12, currency)} / year + <> + {' '} + + + + {scopedT('pro.mostPopularLabel')} + + + + + + {scopedT('heading', { + plan: ( + + Pro + + ), + })} + + {scopedT('pro.description')} + + + + + {formatPrice(89, { currency })} + {scopedT('perMonth')} - )} + + + } + hasArrow + placement="top" + > + + {scopedT('pro.everythingFromStarter')} + + + {scopedT('plus')} + + + + 10,000 {scopedT('chatsPerMonth')} + + {scopedT('chatsTooltip')} + + + + Extra chats:{' '} + + + , + scopedT('pro.whatsAppIntegration'), + scopedT('pro.customDomains'), + scopedT('pro.analytics'), + ]} + /> + + -
- + + ) } diff --git a/apps/builder/src/features/billing/components/StarterPlanPricingCard.tsx b/apps/builder/src/features/billing/components/StarterPlanPricingCard.tsx index 677946875d3..cc4163545b3 100644 --- a/apps/builder/src/features/billing/components/StarterPlanPricingCard.tsx +++ b/apps/builder/src/features/billing/components/StarterPlanPricingCard.tsx @@ -3,174 +3,95 @@ import { Heading, chakra, HStack, - Menu, - MenuButton, Button, - MenuList, - MenuItem, Text, + useColorModeValue, } from '@chakra-ui/react' -import { ChevronLeftIcon } from '@/components/icons' import { Plan } from '@typebot.io/prisma' -import { useEffect, useState } from 'react' -import { isDefined, parseNumberWithCommas } from '@typebot.io/lib' -import { - chatsLimit, - computePrice, - formatPrice, - getChatsLimit, -} from '@typebot.io/lib/pricing' import { FeaturesList } from './FeaturesList' import { MoreInfoTooltip } from '@/components/MoreInfoTooltip' import { useI18n, useScopedI18n } from '@/locales' -import { Workspace } from '@typebot.io/schemas' +import { formatPrice } from '@typebot.io/lib/billing/formatPrice' type Props = { - workspace: Pick< - Workspace, - | 'additionalChatsIndex' - | 'plan' - | 'customChatsLimit' - | 'customStorageLimit' - | 'stripeId' - > - currentSubscription: { - isYearly?: boolean - } + currentPlan: Plan currency?: 'eur' | 'usd' isLoading?: boolean - isYearly: boolean - onPayClick: (props: { selectedChatsLimitIndex: number }) => void + onPayClick: () => void } export const StarterPlanPricingCard = ({ - workspace, - currentSubscription, + currentPlan, isLoading, currency, - isYearly, onPayClick, }: Props) => { const t = useI18n() const scopedT = useScopedI18n('billing.pricingCard') - const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] = - useState() - - useEffect(() => { - if (isDefined(selectedChatsLimitIndex)) return - if (workspace.plan !== Plan.STARTER) { - setSelectedChatsLimitIndex(0) - return - } - setSelectedChatsLimitIndex(workspace.additionalChatsIndex ?? 0) - }, [selectedChatsLimitIndex, workspace.additionalChatsIndex, workspace.plan]) - - const workspaceChatsLimit = workspace ? getChatsLimit(workspace) : undefined - - const isCurrentPlan = - chatsLimit[Plan.STARTER].graduatedPrice[selectedChatsLimitIndex ?? 0] - .totalIncluded === workspaceChatsLimit && - isYearly === currentSubscription?.isYearly const getButtonLabel = () => { - if (selectedChatsLimitIndex === undefined) return '' - if (workspace?.plan === Plan.PRO) return t('downgrade') - if (workspace?.plan === Plan.STARTER) { - if (isCurrentPlan) return scopedT('upgradeButton.current') - - if ( - selectedChatsLimitIndex !== workspace.additionalChatsIndex || - isYearly !== currentSubscription?.isYearly - ) - return t('update') - } + if (currentPlan === Plan.PRO) return t('downgrade') + if (currentPlan === Plan.STARTER) return scopedT('upgradeButton.current') return t('upgrade') } - const handlePayClick = async () => { - if (selectedChatsLimitIndex === undefined) return - onPayClick({ - selectedChatsLimitIndex, - }) - } - - const price = - computePrice( - Plan.STARTER, - selectedChatsLimitIndex ?? 0, - isYearly ? 'yearly' : 'monthly' - ) ?? NaN - return ( - + - - {scopedT('heading', { - plan: Starter, - })} - - {scopedT('starter.description')} - - {formatPrice(price, currency)} - {scopedT('perMonth')} - + + + + {scopedT('heading', { + plan: Starter, + })} + + {scopedT('starter.description')} + + + {formatPrice(39, { currency })} + {scopedT('perMonth')} + + + - - - } - size="sm" - isLoading={selectedChatsLimitIndex === undefined} - > - {selectedChatsLimitIndex !== undefined - ? parseNumberWithCommas( - chatsLimit.STARTER.graduatedPrice[ - selectedChatsLimitIndex - ].totalIncluded - ) - : undefined} - - - {chatsLimit.STARTER.graduatedPrice.map((price, index) => ( - setSelectedChatsLimitIndex(index)} - > - {parseNumberWithCommas(price.totalIncluded)} - - ))} - - {' '} - {scopedT('chatsPerMonth')} + + + 2,000 {scopedT('chatsPerMonth')} + {scopedT('chatsTooltip')} + + + Extra chats: $10 per 500 - {scopedT('chatsTooltip')} - , + , scopedT('starter.brandingRemoved'), scopedT('starter.fileUploadBlock'), scopedT('starter.createFolders'), ]} /> - - {isYearly && workspace.stripeId && !isCurrentPlan && ( - - You pay: {formatPrice(price * 12, currency)} / year - - )} - - + ) } diff --git a/apps/builder/src/features/billing/components/UsageProgressBars.tsx b/apps/builder/src/features/billing/components/UsageProgressBars.tsx index 5388885c005..ee2628e5d8b 100644 --- a/apps/builder/src/features/billing/components/UsageProgressBars.tsx +++ b/apps/builder/src/features/billing/components/UsageProgressBars.tsx @@ -12,9 +12,9 @@ import { AlertIcon } from '@/components/icons' import { Workspace } from '@typebot.io/prisma' import React from 'react' import { parseNumberWithCommas } from '@typebot.io/lib' -import { getChatsLimit } from '@typebot.io/lib/pricing' import { defaultQueryOptions, trpc } from '@/lib/trpc' import { useScopedI18n } from '@/locales' +import { getChatsLimit } from '@typebot.io/lib/billing/getChatsLimit' type Props = { workspace: Workspace @@ -65,7 +65,7 @@ export const UsageProgressBars = ({ workspace }: Props) => { )} - {scopedT('chats.resetInfo')} + (Resets on {data?.resetsAt.toLocaleDateString()}) @@ -90,9 +90,8 @@ export const UsageProgressBars = ({ workspace }: Props) => { h="5px" value={chatsPercentage} rounded="full" - hasStripe isIndeterminate={isLoading} - colorScheme={totalChatsUsed >= workspaceChatsLimit ? 'red' : 'blue'} + colorScheme={'blue'} /> diff --git a/apps/builder/src/features/billing/helpers/parseSubscriptionItems.ts b/apps/builder/src/features/billing/helpers/parseSubscriptionItems.ts deleted file mode 100644 index 276460bde5d..00000000000 --- a/apps/builder/src/features/billing/helpers/parseSubscriptionItems.ts +++ /dev/null @@ -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, - }), - }, - ] - : [] - ) -} diff --git a/apps/builder/src/features/dashboard/components/DashboardPage.tsx b/apps/builder/src/features/dashboard/components/DashboardPage.tsx index 428617b3606..aefedb585ad 100644 --- a/apps/builder/src/features/dashboard/components/DashboardPage.tsx +++ b/apps/builder/src/features/dashboard/components/DashboardPage.tsx @@ -10,12 +10,12 @@ import { Stack, VStack, Spinner, Text } from '@chakra-ui/react' import { Plan } from '@typebot.io/prisma' import { useRouter } from 'next/router' import { useState, useEffect } from 'react' -import { guessIfUserIsEuropean } from '@typebot.io/lib/pricing' import { DashboardHeader } from './DashboardHeader' import { FolderContent } from '@/features/folders/components/FolderContent' import { TypebotDndProvider } from '@/features/folders/TypebotDndProvider' import { ParentModalProvider } from '@/features/graph/providers/ParentModalProvider' import { trpc } from '@/lib/trpc' +import { guessIfUserIsEuropean } from '@typebot.io/lib/billing/guessIfUserIsEuropean' export const DashboardPage = () => { const scopedT = useScopedI18n('dashboard') @@ -33,13 +33,11 @@ export const DashboardPage = () => { }) useEffect(() => { - const { subscribePlan, chats, isYearly, claimCustomPlan } = - router.query as { - subscribePlan: Plan | undefined - chats: string | undefined - isYearly: string | undefined - claimCustomPlan: string | undefined - } + const { subscribePlan, claimCustomPlan } = router.query as { + subscribePlan: Plan | undefined + chats: string | undefined + claimCustomPlan: string | undefined + } if (claimCustomPlan && user?.email && workspace) { setIsLoading(true) createCustomCheckoutSession({ @@ -53,9 +51,7 @@ export const DashboardPage = () => { setPreCheckoutPlan({ plan: subscribePlan as 'PRO' | 'STARTER', workspaceId: workspace.id, - additionalChats: chats ? parseInt(chats) : 0, currency: guessIfUserIsEuropean() ? 'eur' : 'usd', - isYearly: isYearly === 'false' ? false : true, }) } }, [createCustomCheckoutSession, router.query, user, workspace]) diff --git a/apps/builder/src/features/results/components/ResultsPage.tsx b/apps/builder/src/features/results/components/ResultsPage.tsx index 55e00793d08..a488535a3d7 100644 --- a/apps/builder/src/features/results/components/ResultsPage.tsx +++ b/apps/builder/src/features/results/components/ResultsPage.tsx @@ -18,7 +18,6 @@ import { useMemo } from 'react' import { useStats } from '../hooks/useStats' import { ResultsProvider } from '../ResultsProvider' import { ResultsTableContainer } from './ResultsTableContainer' -import { UsageAlertBanners } from './UsageAlertBanners' export const ResultsPage = () => { const router = useRouter() @@ -56,7 +55,6 @@ export const ResultsPage = () => { } /> - {workspace && } { - 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 && ( - - - Your workspace collected {chatsLimitPercentage}% of - your total chats limit this month. Upgrade your plan to continue - chatting with your customers beyond this limit. - - - )} - - ) -} diff --git a/apps/builder/src/features/workspace/components/MembersList.tsx b/apps/builder/src/features/workspace/components/MembersList.tsx index defa31c7050..91ade044dbc 100644 --- a/apps/builder/src/features/workspace/components/MembersList.tsx +++ b/apps/builder/src/features/workspace/components/MembersList.tsx @@ -8,7 +8,6 @@ import { import { UnlockPlanAlertInfo } from '@/components/UnlockPlanAlertInfo' import { WorkspaceInvitation, WorkspaceRole } from '@typebot.io/prisma' import React from 'react' -import { getSeatsLimit, isSeatsLimitReached } from '@typebot.io/lib/pricing' import { AddMemberForm } from './AddMemberForm' import { MemberItem } from './MemberItem' import { isDefined } from '@typebot.io/lib' @@ -21,6 +20,7 @@ import { updateMemberQuery } from '../queries/updateMemberQuery' import { Member } from '../types' import { useWorkspace } from '../WorkspaceProvider' import { useScopedI18n } from '@/locales' +import { getSeatsLimit } from '@typebot.io/lib/billing/getSeatsLimit' export const MembersList = () => { const scopedT = useScopedI18n('workspace.membersList') @@ -92,13 +92,9 @@ export const MembersList = () => { const seatsLimit = workspace ? getSeatsLimit(workspace) : undefined - const canInviteNewMember = - workspace && - !isSeatsLimitReached({ - plan: workspace?.plan, - customSeatsLimit: workspace?.customSeatsLimit, - existingMembersAndInvitationsCount: currentMembersCount, - }) + const canInviteNewMember = workspace + ? currentMembersCount < (seatsLimit as number) + : false return ( diff --git a/apps/builder/src/pages/api/stripe/webhook.ts b/apps/builder/src/pages/api/stripe/webhook.ts index 7f7d1810413..6b7884c1db6 100644 --- a/apps/builder/src/pages/api/stripe/webhook.ts +++ b/apps/builder/src/pages/api/stripe/webhook.ts @@ -46,23 +46,22 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => { const metadata = session.metadata as unknown as | { plan: 'STARTER' | 'PRO' - additionalChats: string workspaceId: string userId: string } | { claimableCustomPlanId: string; userId: string } if ('plan' in metadata) { - const { workspaceId, plan, additionalChats } = metadata - if (!workspaceId || !plan || !additionalChats) + const { workspaceId, plan } = metadata + if (!workspaceId || !plan) return res .status(500) .send({ message: `Couldn't retrieve valid metadata` }) + const workspace = await prisma.workspace.update({ where: { id: workspaceId }, data: { plan, stripeId: session.customer as string, - additionalChatsIndex: parseInt(additionalChats), isQuarantined: false, }, include: { @@ -84,7 +83,6 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => { userId: user.id, data: { plan, - additionalChatsIndex: parseInt(additionalChats), }, }, ]) @@ -119,7 +117,6 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => { userId, data: { plan: Plan.CUSTOM, - additionalChatsIndex: 0, }, }, ]) @@ -148,7 +145,6 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => { }, data: { plan: Plan.FREE, - additionalChatsIndex: 0, customChatsLimit: null, customStorageLimit: null, customSeatsLimit: null, @@ -172,7 +168,6 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => { userId: user.id, data: { plan: Plan.FREE, - additionalChatsIndex: 0, }, }, ]) diff --git a/apps/builder/src/pages/api/workspaces/[workspaceId]/invitations.ts b/apps/builder/src/pages/api/workspaces/[workspaceId]/invitations.ts index 79b9d80e460..1d937f4ad91 100644 --- a/apps/builder/src/pages/api/workspaces/[workspaceId]/invitations.ts +++ b/apps/builder/src/pages/api/workspaces/[workspaceId]/invitations.ts @@ -8,7 +8,7 @@ import { } from '@typebot.io/lib/api' import { getAuthenticatedUser } from '@/features/auth/helpers/getAuthenticatedUser' import { sendWorkspaceMemberInvitationEmail } from '@typebot.io/emails' -import { isSeatsLimitReached } from '@typebot.io/lib/pricing' +import { getSeatsLimit } from '@typebot.io/lib/billing/getSeatsLimit' import { env } from '@typebot.io/env' const handler = async (req: NextApiRequest, res: NextApiResponse) => { @@ -37,11 +37,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { }), ]) if ( - isSeatsLimitReached({ - existingMembersAndInvitationsCount: - existingMembersCount + existingInvitationsCount, - ...workspace, - }) + getSeatsLimit(workspace) <= + existingMembersCount + existingInvitationsCount ) return res.status(400).send('Seats limit reached') if (existingUser) { diff --git a/apps/builder/src/test/utils/databaseActions.ts b/apps/builder/src/test/utils/databaseActions.ts index d3872168d6d..8c4269190b9 100644 --- a/apps/builder/src/test/utils/databaseActions.ts +++ b/apps/builder/src/test/utils/databaseActions.ts @@ -18,7 +18,7 @@ const stripe = new Stripe(env.STRIPE_SECRET_KEY ?? '', { export const addSubscriptionToWorkspace = async ( workspaceId: string, items: Stripe.SubscriptionCreateParams.Item[], - metadata: Pick + metadata: Pick ) => { const { id: stripeId } = await stripe.customers.create({ email: 'test-user@gmail.com', diff --git a/apps/docs/docs/self-hosting/configuration.md b/apps/docs/docs/self-hosting/configuration.md index 8664e68aef9..f4498c1cb79 100644 --- a/apps/docs/docs/self-hosting/configuration.md +++ b/apps/docs/docs/self-hosting/configuration.md @@ -228,25 +228,15 @@ The related environment variables are listed here but you are probably not inter

Stripe

-| Parameter | Default | Description | -| --------------------------------------- | ------- | ------------------------------------------- | -| NEXT_PUBLIC_STRIPE_PUBLIC_KEY | | Stripe public key | -| STRIPE_SECRET_KEY | | Stripe secret key | -| STRIPE_STARTER_PRODUCT_ID | | Starter plan product ID | -| STRIPE_STARTER_MONTHLY_PRICE_ID | | Starter monthly plan price id | -| STRIPE_STARTER_YEARLY_PRICE_ID | | Starter yearly plan price id | -| STRIPE_PRO_PRODUCT_ID | | Pro plan product ID | -| STRIPE_PRO_MONTHLY_PRICE_ID | | Pro monthly plan price id | -| STRIPE_PRO_YEARLY_PRICE_ID | | Pro yearly plan price id | -| STRIPE_STARTER_CHATS_MONTHLY_PRICE_ID | | Starter Additional chats monthly price id | -| STRIPE_STARTER_CHATS_YEARLY_PRICE_ID | | Starter Additional chats yearly price id | -| STRIPE_PRO_CHATS_MONTHLY_PRICE_ID | | Pro Additional chats monthly price id | -| STRIPE_PRO_CHATS_YEARLY_PRICE_ID | | Pro Additional chats yearly price id | -| STRIPE_STARTER_STORAGE_MONTHLY_PRICE_ID | | Starter Additional storage monthly price id | -| STRIPE_STARTER_STORAGE_YEARLY_PRICE_ID | | Starter Additional storage yearly price id | -| STRIPE_PRO_STORAGE_MONTHLY_PRICE_ID | | Pro Additional storage monthly price id | -| STRIPE_PRO_STORAGE_YEARLY_PRICE_ID | | Pro Additional storage yearly price id | -| STRIPE_WEBHOOK_SECRET | | Stripe Webhook secret | +| Parameter | Default | Description | +| ----------------------------- | ------- | ----------------------------------------- | +| NEXT_PUBLIC_STRIPE_PUBLIC_KEY | | Stripe public key | +| STRIPE_SECRET_KEY | | Stripe secret key | +| STRIPE_STARTER_PRICE_ID | | Starter plan price id | +| STRIPE_PRO_PRICE_ID | | Pro monthly plan price id | +| STRIPE_STARTER_CHATS_PRICE_ID | | Starter Additional chats monthly price id | +| STRIPE_PRO_CHATS_PRICE_ID | | Pro Additional chats monthly price id | +| STRIPE_WEBHOOK_SECRET | | Stripe Webhook secret |

diff --git a/apps/docs/openapi/builder/_spec_.json b/apps/docs/openapi/builder/_spec_.json index 02ab65a7a83..23175d546b1 100644 --- a/apps/docs/openapi/builder/_spec_.json +++ b/apps/docs/openapi/builder/_spec_.json @@ -229,14 +229,10 @@ "CUSTOM", "UNLIMITED" ] - }, - "additionalChatsIndex": { - "type": "number" } }, "required": [ - "plan", - "additionalChatsIndex" + "plan" ], "additionalProperties": false } @@ -643,6 +639,14 @@ "youtube", "vimeo" ] + }, + "height": { + "anyOf": [ + { + "type": "number" + }, + {} + ] } }, "additionalProperties": false @@ -5032,6 +5036,14 @@ "youtube", "vimeo" ] + }, + "height": { + "anyOf": [ + { + "type": "number" + }, + {} + ] } }, "additionalProperties": false @@ -9062,6 +9074,14 @@ "youtube", "vimeo" ] + }, + "height": { + "anyOf": [ + { + "type": "number" + }, + {} + ] } }, "additionalProperties": false @@ -13232,6 +13252,14 @@ "youtube", "vimeo" ] + }, + "height": { + "anyOf": [ + { + "type": "number" + }, + {} + ] } }, "additionalProperties": false @@ -17282,6 +17310,14 @@ "youtube", "vimeo" ] + }, + "height": { + "anyOf": [ + { + "type": "number" + }, + {} + ] } }, "additionalProperties": false @@ -21387,6 +21423,14 @@ "youtube", "vimeo" ] + }, + "height": { + "anyOf": [ + { + "type": "number" + }, + {} + ] } }, "additionalProperties": false @@ -25555,6 +25599,14 @@ "youtube", "vimeo" ] + }, + "height": { + "anyOf": [ + { + "type": "number" + }, + {} + ] } }, "additionalProperties": false @@ -30419,9 +30471,6 @@ "returnUrl": { "type": "string" }, - "additionalChats": { - "type": "number" - }, "vat": { "type": "object", "properties": { @@ -30437,9 +30486,6 @@ "value" ], "additionalProperties": false - }, - "isYearly": { - "type": "boolean" } }, "required": [ @@ -30448,9 +30494,7 @@ "workspaceId", "currency", "plan", - "returnUrl", - "additionalChats", - "isYearly" + "returnUrl" ], "additionalProperties": false } @@ -30516,27 +30560,19 @@ "PRO" ] }, - "additionalChats": { - "type": "number" - }, "currency": { "type": "string", "enum": [ "usd", "eur" ] - }, - "isYearly": { - "type": "boolean" } }, "required": [ "returnUrl", "workspaceId", "plan", - "additionalChats", - "currency", - "isYearly" + "currency" ], "additionalProperties": false } @@ -30706,8 +30742,23 @@ { "type": "object", "properties": { - "isYearly": { - "type": "boolean" + "currentBillingPeriod": { + "type": "object", + "properties": { + "start": { + "type": "string", + "format": "date-time" + }, + "end": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false }, "currency": { "type": "string", @@ -30729,7 +30780,7 @@ } }, "required": [ - "isYearly", + "currentBillingPeriod", "currency", "status" ], @@ -30790,10 +30841,15 @@ "properties": { "totalChatsUsed": { "type": "number" + }, + "resetsAt": { + "type": "string", + "format": "date-time" } }, "required": [ - "totalChatsUsed" + "totalChatsUsed", + "resetsAt" ], "additionalProperties": false } diff --git a/apps/docs/openapi/chat/_spec_.json b/apps/docs/openapi/chat/_spec_.json index 833ebd83b6f..367f2e0d538 100644 --- a/apps/docs/openapi/chat/_spec_.json +++ b/apps/docs/openapi/chat/_spec_.json @@ -238,6 +238,14 @@ "youtube", "vimeo" ] + }, + "height": { + "anyOf": [ + { + "type": "number" + }, + {} + ] } }, "additionalProperties": false @@ -4196,6 +4204,14 @@ "youtube", "vimeo" ] + }, + "height": { + "anyOf": [ + { + "type": "number" + }, + {} + ] } }, "additionalProperties": false @@ -5674,9 +5690,6 @@ }, "additionalProperties": false } - }, - "displayStream": { - "type": "boolean" } }, "required": [ diff --git a/apps/landing-page/components/PricingPage/ChatsProTiersModal.tsx b/apps/landing-page/components/PricingPage/ChatsProTiersModal.tsx new file mode 100644 index 00000000000..af7b210456a --- /dev/null +++ b/apps/landing-page/components/PricingPage/ChatsProTiersModal.tsx @@ -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 ( + + + + + Chats pricing table + + + + + + + + + + + + + + {proChatTiers.map((tier, index) => { + const pricePerMonth = + proChatTiers + .slice(0, index + 1) + .reduce( + (acc, slicedTier) => + acc + (slicedTier.flat_amount ?? 0), + 0 + ) / 100 + return ( + + + + + + ) + })} + +
Max chatsPrice per monthPrice per 1k chats
+ {tier.up_to === 'inf' + ? '2,000,000+' + : tier.up_to.toLocaleString()} + + {index === 0 ? 'included' : formatPrice(pricePerMonth)} + + {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 } + )} +
+
+
+ +
+
+ ) +} diff --git a/apps/landing-page/components/PricingPage/Faq.tsx b/apps/landing-page/components/PricingPage/Faq.tsx index 60060cf4520..1ac4ddbd4d1 100644 --- a/apps/landing-page/components/PricingPage/Faq.tsx +++ b/apps/landing-page/components/PricingPage/Faq.tsx @@ -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 = () => ( Frequently asked questions - - - - What is considered a monthly chat? - - - A chat is counted whenever a user starts a discussion. It is - independant of the number of messages he sends and receives. For - example if a user starts a discussion and sends 10 messages to the - bot, it will count as 1 chat. If the user chats again later and its - session is remembered, it will not be counted as a new chat.
-
- An easy way to think about it: 1 chat equals to a row in your Results - table -
-
- - - What happens once I reach the monthly chats limit? - - - You will receive a heads up email when you reach 80% of your monthly - limit. Once you have reached the limit, you will receive another email - alert. Your bots will continue to run. You will be kindly asked to - upgrade your subscription. If you don't provide an answer after - ~48h, your bots will be closed for the remaining of the month. For a - FREE workspace, If you exceed 600 chats, your bots will be - automatically closed. - - - - - What is considered as storage? - - - You accumulate storage for every file that your user upload into your - bot. If you delete the associated result, it will free up the used - space. - - - - - What happens once I reach the storage limit? - - - When you exceed the storage size included in your plan, you will - receive a heads up by email. There won't be any immediate - additional charges and your bots will continue to store new files. If - you continue to exceed the limit, you will be kindly asked you to - upgrade your subscription. - - - - - Can I cancel or change my subscription any time? - - - Yes, you can cancel, upgrade or downgrade your subscription at any - time. There is no minimum time commitment or lock-in. -
-
- When you upgrade or downgrade your subscription, you'll get - access to the new options right away. Your next invoice will have a - prorated amount. -
-
- - - Do you offer annual payments? - - - Yes. Starter and Pro plans can be purchased with monthly or annual - billing. -
-
- Annual plans are cheaper and give you a 16% discount compared to - monthly payments. Enterprise plans are only available with annual - billing. -
-
-
+ + + + + What is considered a monthly chat? + + + A chat is counted whenever a user starts a discussion. It is + independant of the number of messages he sends and receives. For + example if a user starts a discussion and sends 10 messages to the + bot, it will count as 1 chat. If the user chats again later and its + session is remembered, it will not be counted as a new chat.
+
+ An easy way to think about it: 1 chat equals to a row in your + Results table +
+
+
+ + + + + What happens once I reach the included chats limit? + + + That's amazing, your bots are working full speed. 🚀 +
+
+ You will first receive a heads up email when you reach 80% of your + included limit. Once you have reached 100%, you will receive another + email notification. +
+
+ After that, your chat limit be automatically upgraded to the next + tier. +
+
+
+ + + + + Can I cancel or change my subscription any time? + + + Yes, you can cancel, upgrade or downgrade your subscription at any + time. There is no minimum time commitment or lock-in. +
+
+ When you upgrade or downgrade your subscription, you'll get + access to the new options right away. Your next invoice will have a + prorated amount. +
+
+
+ + + + Do you offer annual payments? + + + No, because subscriptions pricing is based on chats usage, we can + only offer monthly plans. + + + +
) diff --git a/apps/landing-page/components/PricingPage/FreePlanCard.tsx b/apps/landing-page/components/PricingPage/FreePlanCard.tsx index 7c4001ce339..c91925120e4 100644 --- a/apps/landing-page/components/PricingPage/FreePlanCard.tsx +++ b/apps/landing-page/components/PricingPage/FreePlanCard.tsx @@ -3,7 +3,7 @@ import { HelpCircleIcon } from 'assets/icons/HelpCircleIcon' import Link from 'next/link' import React from 'react' import { PricingCard } from './PricingCard' -import { chatsLimit } from '@typebot.io/lib/pricing' +import { chatsLimits } from '@typebot.io/lib/billing/constants' export const FreePlanCard = () => ( ( 'Unlimited typebots', <> - - {chatsLimit.FREE.totalIncluded} - {' '} + {chatsLimits.FREE}{' '} chats/month   diff --git a/apps/landing-page/components/PricingPage/PlanComparisonTables.tsx b/apps/landing-page/components/PricingPage/PlanComparisonTables.tsx index a6f61cb9697..caeb2ad648c 100644 --- a/apps/landing-page/components/PricingPage/PlanComparisonTables.tsx +++ b/apps/landing-page/components/PricingPage/PlanComparisonTables.tsx @@ -19,15 +19,19 @@ import { HelpCircleIcon } from 'assets/icons/HelpCircleIcon' import { Plan } from '@typebot.io/prisma' import Link from 'next/link' import React from 'react' +import { parseNumberWithCommas } from '@typebot.io/lib' import { - chatsLimit, - formatPrice, + chatsLimits, prices, - seatsLimit, -} from '@typebot.io/lib/pricing' -import { parseNumberWithCommas } from '@typebot.io/lib' + 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) => ( @@ -50,32 +54,23 @@ export const PlanComparisonTables = () => ( - - - + + + - @@ -87,8 +82,8 @@ export const PlanComparisonTables = () => ( - - + + @@ -276,6 +271,14 @@ export const PlanComparisonTables = () => ( + + + +
Chats{chatsLimit.FREE.totalIncluded} / month - {parseNumberWithCommas( - chatsLimit.STARTER.graduatedPrice[0].totalIncluded - )}{' '} - / month - - {parseNumberWithCommas( - chatsLimit.PRO.graduatedPrice[0].totalIncluded - )}{' '} - / month - {chatsLimits.FREE} / month{parseNumberWithCommas(chatsLimits.STARTER)} / month{parseNumberWithCommas(chatsLimits.PRO)} / month
Additional Chats + {formatPrice(10)} per 500 chats - {formatPrice(chatsLimit.STARTER.graduatedPrice[1].price)} per{' '} - {chatsLimit.STARTER.graduatedPrice[1].totalIncluded - - chatsLimit.STARTER.graduatedPrice[0].totalIncluded} - - {formatPrice(chatsLimit.PRO.graduatedPrice[1].price)} per{' '} - {chatsLimit.PRO.graduatedPrice[1].totalIncluded - - chatsLimit.PRO.graduatedPrice[0].totalIncluded} +
Members Just you{seatsLimit.STARTER.totalIncluded} seats{seatsLimit.PRO.totalIncluded} seats{seatsLimits.STARTER} seats{seatsLimits.PRO} seats
Guests
WhatsApp integration + + + +
Custom domains diff --git a/apps/landing-page/components/PricingPage/PricingCard/index.tsx b/apps/landing-page/components/PricingPage/PricingCard/index.tsx index 3bda0a7d24a..4fe4906ce3e 100644 --- a/apps/landing-page/components/PricingPage/PricingCard/index.tsx +++ b/apps/landing-page/components/PricingPage/PricingCard/index.tsx @@ -9,9 +9,9 @@ import { VStack, } from '@chakra-ui/react' import * as React from 'react' -import { formatPrice } from '@typebot.io/lib/pricing' import { CheckCircleIcon } from '../../../assets/icons/CheckCircleIcon' import { Card, CardProps } from './Card' +import { formatPrice } from '@typebot.io/lib/billing/formatPrice' export interface PricingCardData { features: React.ReactNode[] diff --git a/apps/landing-page/components/PricingPage/ProPlanCard.tsx b/apps/landing-page/components/PricingPage/ProPlanCard.tsx index ed194f6fcdd..bb6108369a7 100644 --- a/apps/landing-page/components/PricingPage/ProPlanCard.tsx +++ b/apps/landing-page/components/PricingPage/ProPlanCard.tsx @@ -4,107 +4,75 @@ import { Text, Button, HStack, - Menu, - MenuButton, - MenuItem, - MenuList, + Stack, + Link, } from '@chakra-ui/react' -import { ChevronDownIcon } from 'assets/icons/ChevronDownIcon' import { HelpCircleIcon } from 'assets/icons/HelpCircleIcon' import { Plan } from '@typebot.io/prisma' -import Link from 'next/link' -import React, { useState } from 'react' -import { parseNumberWithCommas } from '@typebot.io/lib' -import { chatsLimit, computePrice, seatsLimit } from '@typebot.io/lib/pricing' +import React from 'react' import { PricingCard } from './PricingCard' +import { prices, seatsLimits } from '@typebot.io/lib/billing/constants' type Props = { - isYearly: boolean + onChatsTiersClick: () => void } -export const ProPlanCard = ({ isYearly }: Props) => { - const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] = - useState(0) - - const price = - computePrice( - Plan.PRO, - selectedChatsLimitIndex ?? 0, - isYearly ? 'yearly' : 'monthly' - ) ?? NaN - - return ( - - - {seatsLimit.PRO.totalIncluded} seats - {' '} - included - , - - - } - size="sm" - variant="outline" - isLoading={selectedChatsLimitIndex === undefined} - > - {selectedChatsLimitIndex !== undefined - ? parseNumberWithCommas( - chatsLimit.PRO.graduatedPrice[selectedChatsLimitIndex] - .totalIncluded - ) - : undefined} - - - {chatsLimit.PRO.graduatedPrice.map((price, index) => ( - setSelectedChatsLimitIndex(index)} - > - {parseNumberWithCommas(price.totalIncluded)} - - ))} - - {' '} - chats/mo +export const ProPlanCard = ({ onChatsTiersClick }: Props) => ( + + {seatsLimits.PRO} seats{' '} + included + , + + + 10,000 chats/mo - , - 'WhatsApp integration', - 'Custom domains', - 'In-depth analytics', - ], - }} - borderWidth="3px" - borderColor="blue.200" - button={ - - } - /> - ) -} + + + Extra chats:{' '} + + + , + 'WhatsApp integration', + 'Custom domains', + 'In-depth analytics', + ], + }} + borderWidth="3px" + borderColor="blue.200" + button={ + + } + /> +) diff --git a/apps/landing-page/components/PricingPage/StarterPlanCard.tsx b/apps/landing-page/components/PricingPage/StarterPlanCard.tsx index 9a89c7c81e9..15db45a1d71 100644 --- a/apps/landing-page/components/PricingPage/StarterPlanCard.tsx +++ b/apps/landing-page/components/PricingPage/StarterPlanCard.tsx @@ -1,87 +1,43 @@ -import { - chakra, - Tooltip, - Text, - HStack, - Menu, - MenuButton, - Button, - MenuItem, - MenuList, -} from '@chakra-ui/react' -import { ChevronDownIcon } from 'assets/icons/ChevronDownIcon' +import { chakra, Tooltip, Text, HStack, Button, Stack } from '@chakra-ui/react' import { HelpCircleIcon } from 'assets/icons/HelpCircleIcon' import { Plan } from '@typebot.io/prisma' import Link from 'next/link' -import React, { useState } from 'react' -import { parseNumberWithCommas } from '@typebot.io/lib' -import { chatsLimit, computePrice, seatsLimit } from '@typebot.io/lib/pricing' +import React from 'react' import { PricingCard } from './PricingCard' +import { prices, seatsLimits } from '@typebot.io/lib/billing/constants' -type Props = { - isYearly: boolean -} -export const StarterPlanCard = ({ isYearly }: Props) => { - const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] = - useState(0) - - const price = - computePrice( - Plan.STARTER, - selectedChatsLimitIndex ?? 0, - isYearly ? 'yearly' : 'monthly' - ) ?? NaN - +export const StarterPlanCard = () => { return ( - {seatsLimit.STARTER.totalIncluded} seats + {seatsLimits.STARTER} seats {' '} included , - - - } - size="sm" - variant="outline" - colorScheme="orange" - > - {parseNumberWithCommas( - chatsLimit.STARTER.graduatedPrice[selectedChatsLimitIndex] - .totalIncluded - )} - - - {chatsLimit.STARTER.graduatedPrice.map((price, index) => ( - setSelectedChatsLimitIndex(index)} - > - {parseNumberWithCommas(price.totalIncluded)} - - ))} - - {' '} - chats/mo - + + 2,000 chats/mo + - - - - - , + > + + + + + + + Extra chats: $10 per 500 + + , 'Branding removed', 'Collect files from users', 'Create folders', @@ -92,7 +48,7 @@ export const StarterPlanCard = ({ isYearly }: Props) => { button={ diff --git a/packages/env/env.ts b/packages/env/env.ts index fe7ec28049a..ad69186bacf 100644 --- a/packages/env/env.ts +++ b/packages/env/env.ts @@ -140,20 +140,10 @@ const stripeEnv = { server: { STRIPE_SECRET_KEY: z.string().min(1).optional(), STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(), - STRIPE_STARTER_PRODUCT_ID: z.string().min(1).optional(), - STRIPE_STARTER_MONTHLY_PRICE_ID: z.string().min(1).optional(), - STRIPE_STARTER_YEARLY_PRICE_ID: z.string().min(1).optional(), - STRIPE_STARTER_CHATS_MONTHLY_PRICE_ID: z.string().min(1).optional(), - STRIPE_STARTER_CHATS_YEARLY_PRICE_ID: z.string().min(1).optional(), - STRIPE_STARTER_STORAGE_MONTHLY_PRICE_ID: z.string().min(1).optional(), - STRIPE_STARTER_STORAGE_YEARLY_PRICE_ID: z.string().min(1).optional(), - STRIPE_PRO_PRODUCT_ID: z.string().min(1).optional(), - STRIPE_PRO_MONTHLY_PRICE_ID: z.string().min(1).optional(), - STRIPE_PRO_YEARLY_PRICE_ID: z.string().min(1).optional(), - STRIPE_PRO_CHATS_MONTHLY_PRICE_ID: z.string().min(1).optional(), - STRIPE_PRO_CHATS_YEARLY_PRICE_ID: z.string().min(1).optional(), - STRIPE_PRO_STORAGE_MONTHLY_PRICE_ID: z.string().min(1).optional(), - STRIPE_PRO_STORAGE_YEARLY_PRICE_ID: z.string().min(1).optional(), + STRIPE_STARTER_PRICE_ID: z.string().min(1).optional(), + STRIPE_STARTER_CHATS_PRICE_ID: z.string().min(1).optional(), + STRIPE_PRO_PRICE_ID: z.string().min(1).optional(), + STRIPE_PRO_CHATS_PRICE_ID: z.string().min(1).optional(), }, client: { NEXT_PUBLIC_STRIPE_PUBLIC_KEY: z.string().min(1).optional(), diff --git a/packages/lib/api/pricing.ts b/packages/lib/api/pricing.ts deleted file mode 100644 index 770c683390b..00000000000 --- a/packages/lib/api/pricing.ts +++ /dev/null @@ -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, - }, - }, -} diff --git a/packages/lib/billing/constants.ts b/packages/lib/billing/constants.ts new file mode 100644 index 00000000000..01c5f6e2550 --- /dev/null +++ b/packages/lib/billing/constants.ts @@ -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[] diff --git a/packages/lib/billing/formatPrice.ts b/packages/lib/billing/formatPrice.ts new file mode 100644 index 00000000000..d8b1730eb77 --- /dev/null +++ b/packages/lib/billing/formatPrice.ts @@ -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) +} diff --git a/packages/lib/billing/getChatsLimit.ts b/packages/lib/billing/getChatsLimit.ts new file mode 100644 index 00000000000..ad938cff0e0 --- /dev/null +++ b/packages/lib/billing/getChatsLimit.ts @@ -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 & { + 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] +} diff --git a/packages/lib/billing/getSeatsLimit.ts b/packages/lib/billing/getSeatsLimit.ts new file mode 100644 index 00000000000..d9ce44ad161 --- /dev/null +++ b/packages/lib/billing/getSeatsLimit.ts @@ -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) => { + if (plan === Plan.UNLIMITED) return -1 + if (plan === Plan.CUSTOM) return customSeatsLimit ? customSeatsLimit : -1 + return seatsLimits[plan] +} diff --git a/packages/lib/billing/guessIfUserIsEuropean.ts b/packages/lib/billing/guessIfUserIsEuropean.ts new file mode 100644 index 00000000000..9cd514807b7 --- /dev/null +++ b/packages/lib/billing/guessIfUserIsEuropean.ts @@ -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) + }) +} diff --git a/packages/lib/package.json b/packages/lib/package.json index 97eaa6b5e58..cf44cc5b4d7 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -27,6 +27,7 @@ "@udecode/plate-common": "^21.1.5", "got": "12.6.0", "minio": "7.1.3", - "remark-slate": "^1.8.6" + "remark-slate": "^1.8.6", + "stripe": "12.13.0" } } diff --git a/packages/lib/pricing.ts b/packages/lib/pricing.ts deleted file mode 100644 index a71727e8b35..00000000000 --- a/packages/lib/pricing.ts +++ /dev/null @@ -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) => { - 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) => { - if (customSeatsLimit) return customSeatsLimit - return seatsLimit[plan].totalIncluded -} - -export const isSeatsLimitReached = ({ - existingMembersAndInvitationsCount, - plan, - customSeatsLimit, -}: { - existingMembersAndInvitationsCount: number -} & Pick) => { - 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) -} diff --git a/packages/schemas/features/billing/subscription.ts b/packages/schemas/features/billing/subscription.ts index b2102dc4fb2..d413959c113 100644 --- a/packages/schemas/features/billing/subscription.ts +++ b/packages/schemas/features/billing/subscription.ts @@ -1,7 +1,10 @@ import { z } from 'zod' export const subscriptionSchema = z.object({ - isYearly: z.boolean(), + currentBillingPeriod: z.object({ + start: z.date(), + end: z.date(), + }), currency: z.enum(['eur', 'usd']), cancelDate: z.date().optional(), status: z.enum(['active', 'past_due']), diff --git a/packages/schemas/features/telemetry.ts b/packages/schemas/features/telemetry.ts index e4c559cb6c7..61fb3a953c0 100644 --- a/packages/schemas/features/telemetry.ts +++ b/packages/schemas/features/telemetry.ts @@ -62,7 +62,6 @@ const subscriptionUpdatedEventSchema = workspaceEvent.merge( name: z.literal('Subscription updated'), data: z.object({ plan: z.nativeEnum(Plan), - additionalChatsIndex: z.number(), }), }) ) diff --git a/packages/scripts/cleanDatabase.ts b/packages/scripts/cleanDatabase.ts index 04dbc4dcde2..bb218eda168 100644 --- a/packages/scripts/cleanDatabase.ts +++ b/packages/scripts/cleanDatabase.ts @@ -180,17 +180,12 @@ const resetBillingProps = async () => { { chatsLimitFirstEmailSentAt: { not: null }, }, - { - storageLimitFirstEmailSentAt: { not: null }, - }, ], }, data: { isQuarantined: false, chatsLimitFirstEmailSentAt: null, - storageLimitFirstEmailSentAt: null, chatsLimitSecondEmailSentAt: null, - storageLimitSecondEmailSentAt: null, }, }) console.log(`Resetted ${count} workspaces.`) diff --git a/packages/scripts/createChatsPrices.ts b/packages/scripts/createChatsPrices.ts new file mode 100644 index 00000000000..a7e22306f27 --- /dev/null +++ b/packages/scripts/createChatsPrices.ts @@ -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() diff --git a/packages/scripts/inspectUser.ts b/packages/scripts/inspectUser.ts index 281c12f62e1..aba6bf9e61b 100644 --- a/packages/scripts/inspectUser.ts +++ b/packages/scripts/inspectUser.ts @@ -39,7 +39,6 @@ const inspectUser = async () => { user: { email: { not: response.email } }, }, }, - additionalChatsIndex: true, additionalStorageIndex: true, typebots: { orderBy: { @@ -82,10 +81,6 @@ const inspectUser = async () => { console.log(' - Name:', workspace.workspace.name) console.log(' Plan:', workspace.workspace.plan) console.log(' Members:', workspace.workspace.members.length + 1) - console.log( - ' Additional chats:', - workspace.workspace.additionalChatsIndex - ) console.log( ' Additional storage:', workspace.workspace.additionalStorageIndex diff --git a/packages/scripts/migrateSubscriptionsToUsageBased.ts b/packages/scripts/migrateSubscriptionsToUsageBased.ts new file mode 100644 index 00000000000..e91a5a67eda --- /dev/null +++ b/packages/scripts/migrateSubscriptionsToUsageBased.ts @@ -0,0 +1,217 @@ +import { PrismaClient } from '@typebot.io/prisma' +import { promptAndSetEnvironment } from './utils' +import { Stripe } from 'stripe' +import { createId } from '@paralleldrive/cuid2' + +const migrateSubscriptionsToUsageBased = async () => { + await promptAndSetEnvironment() + const prisma = new PrismaClient() + + if ( + !process.env.STRIPE_STARTER_CHATS_PRICE_ID || + !process.env.STRIPE_PRO_CHATS_PRICE_ID + ) + throw new Error( + 'Missing STRIPE_STARTER_CHATS_PRICE_ID or STRIPE_PRO_CHATS_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 }, + }, + }, + }, + }, + }) + + const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { + apiVersion: '2022-11-15', + }) + + const todayMidnight = new Date() + todayMidnight.setUTCHours(0, 0, 0, 0) + + for (const workspace of workspacesWithPaidPlan) { + console.log( + 'Migrating workspace:', + workspace.id, + workspace.name, + JSON.stringify(workspace.members.map((member) => member.user.email)) + ) + if (!workspace.stripeId) { + console.log('No stripe ID, skipping...') + 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) + continue + } + + if ( + !currentSubscription.items.data.find( + (item) => + item.price.id === process.env.STRIPE_STARTER_PRICE_ID || + item.price.id === process.env.STRIPE_PRO_PRICE_ID + ) + ) { + console.log( + 'Could not find STARTER or PRO plan in items for workspace:', + workspace.id + ) + continue + } + + const subscription = await stripe.subscriptions.update( + currentSubscription.id, + { + items: [ + ...currentSubscription.items.data.flatMap( + (item) => { + if ( + item.price.id === process.env.STRIPE_STARTER_PRICE_ID || + item.price.id === process.env.STRIPE_PRO_PRICE_ID + ) + return { + id: item.id, + price: item.price.id, + quantity: item.quantity, + } + if ( + item.price.id === process.env.STRIPE_STARTER_YEARLY_PRICE_ID || + item.price.id === process.env.STRIPE_PRO_YEARLY_PRICE_ID + ) + return [ + { + id: item.id, + price: item.price.id, + quantity: item.quantity, + deleted: true, + }, + { + price: + workspace.plan === 'STARTER' + ? process.env.STRIPE_STARTER_PRICE_ID + : process.env.STRIPE_PRO_PRICE_ID, + quantity: 1, + }, + ] + return { + id: item.id, + price: item.price.id, + quantity: item.quantity, + deleted: true, + } + } + ), + { + price: + workspace.plan === 'STARTER' + ? process.env.STRIPE_STARTER_CHATS_PRICE_ID + : process.env.STRIPE_PRO_CHATS_PRICE_ID, + }, + ], + } + ) + + const totalResults = await prisma.result.count({ + where: { + typebot: { workspaceId: workspace.id }, + hasStarted: true, + createdAt: { + gte: new Date(subscription.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', + workspace.id + ) + const currentPlanItemId = subscription?.items.data.find((item) => + [ + process.env.STRIPE_STARTER_PRICE_ID, + process.env.STRIPE_PRO_PRICE_ID, + ].includes(item.price.id) + )?.id + + await stripe.subscriptions.update(subscription.id, { + items: [ + { + id: currentPlanItemId, + price: process.env.STRIPE_PRO_PRICE_ID, + quantity: 1, + }, + ], + }) + + await prisma.workspace.update({ + where: { id: workspace.id }, + data: { + plan: 'PRO', + }, + }) + } + + 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 ${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() diff --git a/packages/scripts/package.json b/packages/scripts/package.json index c11920b16bd..12ea10d48dd 100644 --- a/packages/scripts/package.json +++ b/packages/scripts/package.json @@ -15,7 +15,8 @@ "telemetry:sendTotalResultsDigest": "tsx sendTotalResultsDigest.ts", "sendAlertEmails": "tsx sendAlertEmails.ts", "inspectUser": "tsx inspectUser.ts", - "checkSubscriptionsStatus": "tsx checkSubscriptionsStatus.ts" + "checkSubscriptionsStatus": "tsx checkSubscriptionsStatus.ts", + "createChatsPrices": "tsx createChatsPrices.ts" }, "devDependencies": { "@typebot.io/emails": "workspace:*", @@ -31,5 +32,8 @@ "tsx": "3.12.7", "typescript": "5.1.6", "zod": "3.21.4" + }, + "dependencies": { + "@paralleldrive/cuid2": "2.2.1" } } diff --git a/packages/scripts/sendAlertEmails.ts b/packages/scripts/sendAlertEmails.ts index a2aa13c6323..36494608cc8 100644 --- a/packages/scripts/sendAlertEmails.ts +++ b/packages/scripts/sendAlertEmails.ts @@ -5,24 +5,23 @@ import { WorkspaceRole, } from '@typebot.io/prisma' import { isDefined } 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 { promptAndSetEnvironment } from './utils' import { Workspace } from '@typebot.io/schemas' import { sendAlmostReachedChatsLimitEmail } from '@typebot.io/emails/src/emails/AlmostReachedChatsLimitEmail' -import { sendReachedChatsLimitEmail } from '@typebot.io/emails/src/emails/ReachedChatsLimitEmail' import { TelemetryEvent } from '@typebot.io/schemas/features/telemetry' import { sendTelemetryEvents } from '@typebot.io/lib/telemetry/sendTelemetryEvent' const prisma = new PrismaClient() -const LIMIT_EMAIL_TRIGGER_PERCENT = 0.8 +const LIMIT_EMAIL_TRIGGER_PERCENT = 0.75 type WorkspaceForDigest = Pick< Workspace, | 'id' | 'plan' + | 'name' | 'customChatsLimit' - | 'additionalChatsIndex' | 'isQuarantined' | 'chatsLimitFirstEmailSentAt' | 'chatsLimitSecondEmailSentAt' @@ -69,11 +68,11 @@ export const sendTotalResultsDigest = async () => { }, select: { id: true, + name: true, typebots: { select: { id: true } }, members: { select: { user: { select: { id: true, email: true } }, role: true }, }, - additionalChatsIndex: true, additionalStorageIndex: true, customChatsLimit: true, customStorageLimit: true, @@ -144,7 +143,7 @@ const sendAlertIfLimitReached = async ( to, usagePercent: Math.round((totalChatsUsed / chatsLimit) * 100), chatsLimit, - url: `https://app.typebot.io/typebots?workspaceId=${workspace.id}`, + workspaceName: workspace.name, }) await prisma.workspace.updateMany({ where: { id: workspace.id }, @@ -155,32 +154,7 @@ const sendAlertIfLimitReached = async ( } } - if ( - chatsLimit > 0 && - totalChatsUsed >= chatsLimit && - !workspace.chatsLimitSecondEmailSentAt - ) { - const to = workspace.members - .filter((member) => member.role === WorkspaceRole.ADMIN) - .map((member) => member.user.email) - .filter(isDefined) - try { - console.log(`Send reached chats limit email to ${to.join(', ')}...`) - await sendReachedChatsLimitEmail({ - to, - chatsLimit, - url: `https://app.typebot.io/typebots?workspaceId=${workspace.id}`, - }) - await prisma.workspace.updateMany({ - where: { id: workspace.id }, - data: { chatsLimitSecondEmailSentAt: new Date() }, - }) - } catch (err) { - console.error(err) - } - } - - if (totalChatsUsed > chatsLimit * 3 && workspace.plan === Plan.FREE) { + if (totalChatsUsed > chatsLimit * 1.5 && workspace.plan === Plan.FREE) { console.log(`Automatically quarantine workspace ${workspace.id}...`) await prisma.workspace.updateMany({ where: { id: workspace.id }, diff --git a/packages/scripts/sendTotalResultsDigest.ts b/packages/scripts/sendTotalResultsDigest.ts index 8c770b060e7..9fbc18c049a 100644 --- a/packages/scripts/sendTotalResultsDigest.ts +++ b/packages/scripts/sendTotalResultsDigest.ts @@ -1,14 +1,17 @@ import { MemberInWorkspace, + Plan, PrismaClient, WorkspaceRole, } from '@typebot.io/prisma' -import { isDefined } from '@typebot.io/lib' -import { getChatsLimit } from '@typebot.io/lib/pricing' +import { isDefined, isEmpty } from '@typebot.io/lib' +import { getChatsLimit } from '@typebot.io/lib/billing/getChatsLimit' import { 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' +import { Stripe } from 'stripe' +import { createId } from '@paralleldrive/cuid2' const prisma = new PrismaClient() @@ -18,7 +21,6 @@ type WorkspaceForDigest = Pick< | 'plan' | 'customChatsLimit' | 'customStorageLimit' - | 'additionalChatsIndex' | 'additionalStorageIndex' | 'isQuarantined' > & { @@ -27,6 +29,32 @@ type WorkspaceForDigest = Pick< })[] } +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 sendTotalResultsDigest = async () => { await promptAndSetEnvironment('production') @@ -71,12 +99,12 @@ export const sendTotalResultsDigest = async () => { members: { select: { user: { select: { id: true, email: true } }, role: true }, }, - additionalChatsIndex: true, additionalStorageIndex: true, customChatsLimit: true, customStorageLimit: true, plan: true, isQuarantined: true, + stripeId: true, }, }) @@ -96,7 +124,11 @@ export const sendTotalResultsDigest = async () => { isFirstOfKind: memberIndex === 0 ? (true as const) : undefined, })) }) - .filter(isDefined) + .filter(isDefined) satisfies ResultWithWorkspace[] + + console.log('Reporting usage to Stripe...') + + await reportUsageToStripe(resultsWithWorkspaces) console.log('Computing workspaces limits...') @@ -166,6 +198,69 @@ const sendAlertIfLimitReached = async ( return events } +const reportUsageToStripe = async ( + resultsWithWorkspaces: (Pick & { + 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, + } + ) + } +} + const getUsage = async (workspaceId: string) => { const now = new Date() const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d98f18f039..63ac3618505 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1200,6 +1200,9 @@ importers: remark-slate: specifier: ^1.8.6 version: 1.8.6 + stripe: + specifier: 12.13.0 + version: 12.13.0 devDependencies: '@paralleldrive/cuid2': specifier: 2.2.1 @@ -1277,6 +1280,10 @@ importers: version: 5.1.6 packages/scripts: + dependencies: + '@paralleldrive/cuid2': + specifier: 2.2.1 + version: 2.2.1 devDependencies: '@typebot.io/emails': specifier: workspace:*