diff --git a/apps/builder/src/features/billing/api/cancelSubscription.ts b/apps/builder/src/features/billing/api/cancelSubscription.ts deleted file mode 100644 index 64c3376eb..000000000 --- a/apps/builder/src/features/billing/api/cancelSubscription.ts +++ /dev/null @@ -1,70 +0,0 @@ -import prisma from '@/lib/prisma' -import { authenticatedProcedure } from '@/helpers/server/trpc' -import { TRPCError } from '@trpc/server' -import { Plan, WorkspaceRole } from '@typebot.io/prisma' -import Stripe from 'stripe' -import { z } from 'zod' - -export const cancelSubscription = authenticatedProcedure - .meta({ - openapi: { - method: 'DELETE', - path: '/billing/subscription', - protect: true, - summary: 'Cancel current subscription', - tags: ['Billing'], - }, - }) - .input( - z.object({ - workspaceId: z.string(), - }) - ) - .output( - z.object({ - message: z.literal('success'), - }) - ) - .mutation(async ({ input: { workspaceId }, ctx: { user } }) => { - if ( - !process.env.STRIPE_SECRET_KEY || - !process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID || - !process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID - ) - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Stripe environment variables are missing', - }) - const workspace = await prisma.workspace.findFirst({ - where: { - id: workspaceId, - members: { some: { userId: user.id, role: WorkspaceRole.ADMIN } }, - }, - }) - if (!workspace?.stripeId) - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'Workspace not found', - }) - const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { - apiVersion: '2022-11-15', - }) - const currentSubscriptionId = ( - await stripe.subscriptions.list({ - customer: workspace.stripeId, - }) - ).data.shift()?.id - if (currentSubscriptionId) - await stripe.subscriptions.del(currentSubscriptionId) - - await prisma.workspace.update({ - where: { id: workspace.id }, - data: { - plan: Plan.FREE, - additionalChatsIndex: 0, - additionalStorageIndex: 0, - }, - }) - - return { message: 'success' } - }) diff --git a/apps/builder/src/features/billing/api/createCheckoutSession.ts b/apps/builder/src/features/billing/api/createCheckoutSession.ts index 413f41edf..4fa713f4e 100644 --- a/apps/builder/src/features/billing/api/createCheckoutSession.ts +++ b/apps/builder/src/features/billing/api/createCheckoutSession.ts @@ -33,6 +33,7 @@ export const createCheckoutSession = authenticatedProcedure value: z.string(), }) .optional(), + isYearly: z.boolean(), }) ) .output( @@ -52,14 +53,11 @@ export const createCheckoutSession = authenticatedProcedure returnUrl, additionalChats, additionalStorage, + isYearly, }, ctx: { user }, }) => { - if ( - !process.env.STRIPE_SECRET_KEY || - !process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID || - !process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID - ) + if (!process.env.STRIPE_SECRET_KEY) throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Stripe environment variables are missing', @@ -120,7 +118,8 @@ export const createCheckoutSession = authenticatedProcedure line_items: parseSubscriptionItems( plan, additionalChats, - additionalStorage + additionalStorage, + isYearly ), }) diff --git a/apps/builder/src/features/billing/api/getSubscription.ts b/apps/builder/src/features/billing/api/getSubscription.ts index 530690bcd..ad3dedf05 100644 --- a/apps/builder/src/features/billing/api/getSubscription.ts +++ b/apps/builder/src/features/billing/api/getSubscription.ts @@ -5,6 +5,7 @@ import { WorkspaceRole } from '@typebot.io/prisma' import Stripe from 'stripe' import { z } from 'zod' import { subscriptionSchema } from '@typebot.io/schemas/features/billing/subscription' +import { priceIds } from '@typebot.io/lib/pricing' export const getSubscription = authenticatedProcedure .meta({ @@ -23,15 +24,11 @@ export const getSubscription = authenticatedProcedure ) .output( z.object({ - subscription: subscriptionSchema, + subscription: subscriptionSchema.or(z.null()), }) ) .query(async ({ input: { workspaceId }, ctx: { user } }) => { - if ( - !process.env.STRIPE_SECRET_KEY || - !process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID || - !process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID - ) + if (!process.env.STRIPE_SECRET_KEY) throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Stripe environment variables are missing', @@ -43,10 +40,9 @@ export const getSubscription = authenticatedProcedure }, }) if (!workspace?.stripeId) - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'Workspace not found', - }) + return { + subscription: null, + } const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { apiVersion: '2022-11-15', }) @@ -58,24 +54,34 @@ export const getSubscription = authenticatedProcedure const subscription = subscriptions?.data.shift() if (!subscription) - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'Subscription not found', - }) + return { + subscription: null, + } return { subscription: { - additionalChatsIndex: - subscription?.items.data.find( - (item) => - item.price.id === process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID - )?.quantity ?? 0, - additionalStorageIndex: - subscription.items.data.find( - (item) => - item.price.id === process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID - )?.quantity ?? 0, + isYearly: subscription.items.data.some((item) => { + return ( + priceIds.STARTER.chats.yearly === item.price.id || + priceIds.STARTER.storage.yearly === item.price.id || + priceIds.PRO.chats.yearly === item.price.id || + priceIds.PRO.storage.yearly === item.price.id + ) + }), currency: subscription.currency as 'usd' | 'eur', + cancelDate: subscription.cancel_at + ? new Date(subscription.cancel_at * 1000) + : undefined, }, } }) + +export const chatPriceIds = [priceIds.STARTER.chats.monthly] + .concat(priceIds.STARTER.chats.yearly) + .concat(priceIds.PRO.chats.monthly) + .concat(priceIds.PRO.chats.yearly) + +export const storagePriceIds = [priceIds.STARTER.storage.monthly] + .concat(priceIds.STARTER.storage.yearly) + .concat(priceIds.PRO.storage.monthly) + .concat(priceIds.PRO.storage.yearly) diff --git a/apps/builder/src/features/billing/api/router.ts b/apps/builder/src/features/billing/api/router.ts index ebf943f3c..5655b0d68 100644 --- a/apps/builder/src/features/billing/api/router.ts +++ b/apps/builder/src/features/billing/api/router.ts @@ -1,5 +1,4 @@ import { router } from '@/helpers/server/trpc' -import { cancelSubscription } from './cancelSubscription' import { createCheckoutSession } from './createCheckoutSession' import { getBillingPortalUrl } from './getBillingPortalUrl' import { getSubscription } from './getSubscription' @@ -10,7 +9,6 @@ import { updateSubscription } from './updateSubscription' export const billingRouter = router({ getBillingPortalUrl, listInvoices, - cancelSubscription, createCheckoutSession, updateSubscription, getSubscription, diff --git a/apps/builder/src/features/billing/api/updateSubscription.ts b/apps/builder/src/features/billing/api/updateSubscription.ts index 2b366220c..3afe99a17 100644 --- a/apps/builder/src/features/billing/api/updateSubscription.ts +++ b/apps/builder/src/features/billing/api/updateSubscription.ts @@ -7,6 +7,12 @@ import { workspaceSchema } from '@typebot.io/schemas' import Stripe from 'stripe' import { isDefined } from '@typebot.io/lib' import { z } from 'zod' +import { + getChatsLimit, + getStorageLimit, + priceIds, +} from '@typebot.io/lib/pricing' +import { chatPriceIds, storagePriceIds } from './getSubscription' export const updateSubscription = authenticatedProcedure .meta({ @@ -25,6 +31,7 @@ export const updateSubscription = authenticatedProcedure additionalChats: z.number(), additionalStorage: z.number(), currency: z.enum(['usd', 'eur']), + isYearly: z.boolean(), }) ) .output( @@ -40,14 +47,11 @@ export const updateSubscription = authenticatedProcedure additionalChats, additionalStorage, currency, + isYearly, }, ctx: { user }, }) => { - if ( - !process.env.STRIPE_SECRET_KEY || - !process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID || - !process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID - ) + if (!process.env.STRIPE_SECRET_KEY) throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Stripe environment variables are missing', @@ -70,42 +74,48 @@ export const updateSubscription = authenticatedProcedure customer: workspace.stripeId, }) const subscription = data[0] as Stripe.Subscription | undefined - const currentStarterPlanItemId = subscription?.items.data.find( - (item) => item.price.id === process.env.STRIPE_STARTER_PRICE_ID - )?.id - const currentProPlanItemId = subscription?.items.data.find( - (item) => item.price.id === process.env.STRIPE_PRO_PRICE_ID + const currentPlanItemId = subscription?.items.data.find((item) => + [ + process.env.STRIPE_STARTER_PRODUCT_ID, + process.env.STRIPE_PRO_PRODUCT_ID, + ].includes(item.price.product.toString()) )?.id const currentAdditionalChatsItemId = subscription?.items.data.find( - (item) => item.price.id === process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID + (item) => chatPriceIds.includes(item.price.id) )?.id const currentAdditionalStorageItemId = subscription?.items.data.find( - (item) => - item.price.id === process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID + (item) => storagePriceIds.includes(item.price.id) )?.id + const frequency = isYearly ? 'yearly' : 'monthly' + const items = [ { - id: currentStarterPlanItemId ?? currentProPlanItemId, - price: - plan === Plan.STARTER - ? process.env.STRIPE_STARTER_PRICE_ID - : process.env.STRIPE_PRO_PRICE_ID, + id: currentPlanItemId, + price: priceIds[plan].base[frequency], quantity: 1, }, additionalChats === 0 && !currentAdditionalChatsItemId ? undefined : { id: currentAdditionalChatsItemId, - price: process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID, - quantity: additionalChats, + price: priceIds[plan].chats[frequency], + quantity: getChatsLimit({ + plan, + additionalChatsIndex: additionalChats, + customChatsLimit: null, + }), deleted: subscription ? additionalChats === 0 : undefined, }, additionalStorage === 0 && !currentAdditionalStorageItemId ? undefined : { id: currentAdditionalStorageItemId, - price: process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID, - quantity: additionalStorage, + price: priceIds[plan].storage[frequency], + quantity: getStorageLimit({ + plan, + additionalStorageIndex: additionalStorage, + customStorageLimit: null, + }), deleted: subscription ? additionalStorage === 0 : undefined, }, ].filter(isDefined) @@ -126,6 +136,9 @@ export const updateSubscription = authenticatedProcedure items, currency, default_payment_method: paymentMethods[0].id, + automatic_tax: { + enabled: true, + }, }) } diff --git a/apps/builder/src/features/billing/billing.spec.ts b/apps/builder/src/features/billing/billing.spec.ts index 2ea38b4d9..d6d73a0a4 100644 --- a/apps/builder/src/features/billing/billing.spec.ts +++ b/apps/builder/src/features/billing/billing.spec.ts @@ -1,5 +1,6 @@ import { addSubscriptionToWorkspace, + cancelSubscription, createClaimableCustomPlan, } from '@/test/utils/databaseActions' import test, { expect } from '@playwright/test' @@ -75,7 +76,7 @@ test('should display valid usage', async ({ page }) => { await page.click('text="Free workspace"') await page.click('text=Settings & Members') await page.click('text=Billing & Usage') - await expect(page.locator('text="/ 300"')).toBeVisible() + await expect(page.locator('text="/ 200"')).toBeVisible() await expect(page.locator('text="Storage"')).toBeHidden() await page.getByText('Members', { exact: true }).click() await expect( @@ -132,6 +133,7 @@ test('plan changes should work', async ({ page }) => { 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.getByLabel('Company name').fill('Company LLC') @@ -141,11 +143,11 @@ test('plan changes should work', async ({ page }) => { 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 addSubscriptionToWorkspace( + const stripeId = await addSubscriptionToWorkspace( planChangeWorkspaceId, [ { - price: process.env.STRIPE_STARTER_PRICE_ID, + price: process.env.STRIPE_STARTER_MONTHLY_PRICE_ID, quantity: 1, }, ], @@ -158,8 +160,8 @@ test('plan changes should work', async ({ page }) => { await page.click('text=Billing & Usage') await expect(page.locator('text="/ 2,000"')).toBeVisible() await expect(page.locator('text="/ 2 GB"')).toBeVisible() - await expect(page.locator('button >> text="2,000"')).toBeVisible() - await expect(page.locator('button >> text="2"')).toBeVisible() + await expect(page.getByText('/ 2,000')).toBeVisible() + await expect(page.getByText('/ 2 GB')).toBeVisible() await page.click('button >> text="2,000"') await page.click('button >> text="3,500"') await page.click('button >> text="2"') @@ -176,15 +178,15 @@ test('plan changes should work', async ({ page }) => { await expect(page.locator('text="$73"')).toBeVisible() await expect(page.locator('text="/ 3,500"')).toBeVisible() await expect(page.locator('text="/ 4 GB"')).toBeVisible() - await expect(page.locator('button >> text="3,500"')).toBeVisible() - await expect(page.locator('button >> text="4"')).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="14,000"') + await page.click('button >> text="25,000"') await page.click('button >> text="10"') - await page.click('button >> text="12"') - await expect(page.locator('text="$133"')).toBeVisible() + await page.click('button >> text="15"') + await expect(page.locator('text="$247"')).toBeVisible() await page.click('button >> text=Upgrade') await expect( page.locator('text="Workspace PRO plan successfully updated 🎉" >> nth=0') @@ -195,25 +197,20 @@ 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('(×25000)')).toBeVisible() + await expect(page.getByText('(×15)')).toBeVisible() await expect(page.locator('text="Add payment method"')).toBeVisible() + await cancelSubscription(stripeId) // Cancel subscription await page.goto('/typebots') await page.click('text=Settings & Members') await page.click('text=Billing & Usage') - await expect(page.locator('[data-testid="current-subscription"]')).toHaveText( - 'Current workspace subscription: ProCancel my subscription' - ) - await page.click('button >> text="Cancel my subscription"') - await expect(page.locator('[data-testid="current-subscription"]')).toHaveText( - 'Current workspace subscription: Free' - ) - - // Upgrade again to PRO - await page.getByRole('button', { name: 'Upgrade' }).nth(1).click() await expect( - page.locator('text="Workspace PRO plan successfully updated 🎉" >> nth=0') - ).toBeVisible({ timeout: 20 * 1000 }) + page.getByTestId('current-subscription').getByTestId('pro-plan-tag') + ).toBeVisible() + await expect(page.getByText('Will be cancelled on')).toBeVisible() }) test('should display invoices', async ({ page }) => { @@ -228,7 +225,7 @@ test('should display invoices', async ({ page }) => { await page.click('text=Settings & Members') await page.click('text=Billing & Usage') await expect(page.locator('text="Invoices"')).toBeVisible() - await expect(page.locator('tr')).toHaveCount(3) + await expect(page.locator('tr')).toHaveCount(2) await expect(page.locator('text="$39.00"')).toBeVisible() }) diff --git a/apps/builder/src/features/billing/components/BillingSettingsLayout.tsx b/apps/builder/src/features/billing/components/BillingSettingsLayout.tsx index 934795e1d..a8d07fc35 100644 --- a/apps/builder/src/features/billing/components/BillingSettingsLayout.tsx +++ b/apps/builder/src/features/billing/components/BillingSettingsLayout.tsx @@ -1,17 +1,13 @@ -import { HStack, Stack, Text } from '@chakra-ui/react' +import { Stack } from '@chakra-ui/react' import { useWorkspace } from '@/features/workspace/WorkspaceProvider' import { Plan } from '@typebot.io/prisma' import React from 'react' import { InvoicesList } from './InvoicesList' -import { StripeClimateLogo } from './StripeClimateLogo' -import { TextLink } from '@/components/TextLink' import { ChangePlanForm } from './ChangePlanForm' import { UsageProgressBars } from './UsageProgressBars' import { CurrentSubscriptionSummary } from './CurrentSubscriptionSummary' -import { useScopedI18n } from '@/locales' export const BillingSettingsLayout = () => { - const scopedT = useScopedI18n('billing') const { workspace, refreshWorkspace } = useWorkspace() if (!workspace) return null @@ -19,19 +15,7 @@ export const BillingSettingsLayout = () => { - - - - - {scopedT('contribution.preLink')}{' '} - - {scopedT('contribution.link')} - - - + {workspace.plan !== Plan.CUSTOM && workspace.plan !== Plan.LIFETIME && workspace.plan !== Plan.UNLIMITED && diff --git a/apps/builder/src/features/billing/components/ChangePlanForm.tsx b/apps/builder/src/features/billing/components/ChangePlanForm.tsx index 90f5dafe3..5a7ed902f 100644 --- a/apps/builder/src/features/billing/components/ChangePlanForm.tsx +++ b/apps/builder/src/features/billing/components/ChangePlanForm.tsx @@ -1,4 +1,4 @@ -import { Stack, HStack, Text } from '@chakra-ui/react' +import { Stack, HStack, Text, Switch, Tag } from '@chakra-ui/react' import { Plan } from '@typebot.io/prisma' import { TextLink } from '@/components/TextLink' import { useToast } from '@/hooks/useToast' @@ -12,22 +12,33 @@ import { useUser } from '@/features/account/hooks/useUser' import { StarterPlanPricingCard } from './StarterPlanPricingCard' import { ProPlanPricingCard } from './ProPlanPricingCard' import { useScopedI18n } from '@/locales' +import { StripeClimateLogo } from './StripeClimateLogo' type Props = { - workspace: Pick + workspace: Workspace onUpgradeSuccess: () => void } export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => { const scopedT = useScopedI18n('billing') + const { user } = useUser() const { showToast } = useToast() const [preCheckoutPlan, setPreCheckoutPlan] = useState() + const [isYearly, setIsYearly] = useState(true) - const { data } = trpc.billing.getSubscription.useQuery({ - workspaceId: workspace.id, - }) + const { data } = trpc.billing.getSubscription.useQuery( + { + workspaceId: workspace.id, + }, + { + onSuccess: ({ subscription }) => { + if (isYearly === false) return + setIsYearly(subscription?.isYearly ?? true) + }, + } + ) const { mutate: updateSubscription, isLoading: isUpdatingSubscription } = trpc.billing.updateSubscription.useMutation({ @@ -67,8 +78,9 @@ export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => { additionalChats: selectedChatsLimitIndex, additionalStorage: selectedStorageLimitIndex, currency: - data?.subscription.currency ?? + data?.subscription?.currency ?? (guessIfUserIsEuropean() ? 'eur' : 'usd'), + isYearly, } as const if (workspace.stripeId) { updateSubscription(newSubscription) @@ -77,8 +89,19 @@ export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => { } } + if (data?.subscription?.cancelDate) return null + return ( + + + + {scopedT('contribution.preLink')}{' '} + + {scopedT('contribution.link')} + + + {!workspace.stripeId && ( { /> )} - - - handlePayClick({ ...props, plan: Plan.STARTER }) - } - isLoading={isUpdatingSubscription} - currency={data?.subscription.currency} - /> + {data && ( + + + Monthly + setIsYearly(!isYearly)} + /> + + Yearly + 16% off + + + + + handlePayClick({ ...props, plan: Plan.STARTER }) + } + isYearly={isYearly} + isLoading={isUpdatingSubscription} + currency={data.subscription?.currency} + /> + + + handlePayClick({ ...props, plan: Plan.PRO }) + } + isYearly={isYearly} + isLoading={isUpdatingSubscription} + currency={data.subscription?.currency} + /> + + + )} - handlePayClick({ ...props, plan: Plan.PRO })} - isLoading={isUpdatingSubscription} - currency={data?.subscription.currency} - /> - {scopedT('customLimit.preLink')}{' '} diff --git a/apps/builder/src/features/billing/components/CurrentSubscriptionSummary.tsx b/apps/builder/src/features/billing/components/CurrentSubscriptionSummary.tsx index 5cc3c3013..d31bd474b 100644 --- a/apps/builder/src/features/billing/components/CurrentSubscriptionSummary.tsx +++ b/apps/builder/src/features/billing/components/CurrentSubscriptionSummary.tsx @@ -1,5 +1,4 @@ -import { Text, HStack, Link, Spinner, Stack, Heading } from '@chakra-ui/react' -import { useToast } from '@/hooks/useToast' +import { Text, HStack, Stack, Heading } from '@chakra-ui/react' import { Plan } from '@typebot.io/prisma' import React from 'react' import { PlanTag } from './PlanTag' @@ -10,25 +9,14 @@ import { useScopedI18n } from '@/locales' type Props = { workspace: Pick - onCancelSuccess: () => void } -export const CurrentSubscriptionSummary = ({ - workspace, - onCancelSuccess, -}: Props) => { +export const CurrentSubscriptionSummary = ({ workspace }: Props) => { const scopedT = useScopedI18n('billing.currentSubscription') - const { showToast } = useToast() - const { mutate: cancelSubscription, isLoading: isCancelling } = - trpc.billing.cancelSubscription.useMutation({ - onError: (error) => { - showToast({ - description: error.message, - }) - }, - onSuccess: onCancelSuccess, - }) + const { data } = trpc.billing.getSubscription.useQuery({ + workspaceId: workspace.id, + }) const isSubscribed = (workspace.plan === Plan.STARTER || workspace.plan === Plan.PRO) && @@ -39,36 +27,15 @@ export const CurrentSubscriptionSummary = ({ {scopedT('heading')} {scopedT('subheading')} - {isCancelling ? ( - - ) : ( - <> - - {isSubscribed && ( - - cancelSubscription({ workspaceId: workspace.id }) - } - > - {scopedT('cancelLink')} - - )} - + + {data?.subscription?.cancelDate && ( + + (Will be cancelled on {data.subscription.cancelDate.toDateString()}) + )} - {isSubscribed && !isCancelling && ( - <> - - {scopedT('billingPortalDescription')} - - - - )} + {isSubscribed && } ) } diff --git a/apps/builder/src/features/billing/components/PreCheckoutModal.tsx b/apps/builder/src/features/billing/components/PreCheckoutModal.tsx index 6c083868f..22323647d 100644 --- a/apps/builder/src/features/billing/components/PreCheckoutModal.tsx +++ b/apps/builder/src/features/billing/components/PreCheckoutModal.tsx @@ -28,6 +28,7 @@ export type PreCheckoutModalProps = { additionalChats: number additionalStorage: 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 b2f6e4da6..9e793b7b5 100644 --- a/apps/builder/src/features/billing/components/ProPlanPricingCard.tsx +++ b/apps/builder/src/features/billing/components/ProPlanPricingCard.tsx @@ -15,10 +15,9 @@ import { useColorModeValue, } from '@chakra-ui/react' import { ChevronLeftIcon } from '@/components/icons' -import { useWorkspace } from '@/features/workspace/WorkspaceProvider' import { Plan } from '@typebot.io/prisma' import { useEffect, useState } from 'react' -import { parseNumberWithCommas } from '@typebot.io/lib' +import { isDefined, parseNumberWithCommas } from '@typebot.io/lib' import { chatsLimit, computePrice, @@ -30,12 +29,23 @@ import { import { FeaturesList } from './FeaturesList' import { MoreInfoTooltip } from '@/components/MoreInfoTooltip' import { useI18n, useScopedI18n } from '@/locales' +import { Workspace } from '@typebot.io/schemas' type Props = { - initialChatsLimitIndex?: number - initialStorageLimitIndex?: number + workspace: Pick< + Workspace, + | 'additionalChatsIndex' + | 'additionalStorageIndex' + | 'plan' + | 'customChatsLimit' + | 'customStorageLimit' + > + currentSubscription: { + isYearly?: boolean + } currency?: 'usd' | 'eur' isLoading: boolean + isYearly: boolean onPayClick: (props: { selectedChatsLimitIndex: number selectedStorageLimitIndex: number @@ -43,15 +53,15 @@ type Props = { } export const ProPlanPricingCard = ({ - initialChatsLimitIndex, - initialStorageLimitIndex, + workspace, + currentSubscription, currency, isLoading, + isYearly, onPayClick, }: Props) => { const t = useI18n() const scopedT = useScopedI18n('billing.pricingCard') - const { workspace } = useWorkspace() const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] = useState() const [selectedStorageLimitIndex, setSelectedStorageLimitIndex] = @@ -59,20 +69,23 @@ export const ProPlanPricingCard = ({ useEffect(() => { if ( - selectedChatsLimitIndex === undefined && - initialChatsLimitIndex !== undefined + isDefined(selectedChatsLimitIndex) || + isDefined(selectedStorageLimitIndex) ) - setSelectedChatsLimitIndex(initialChatsLimitIndex) - if ( - selectedStorageLimitIndex === undefined && - initialStorageLimitIndex !== undefined - ) - setSelectedStorageLimitIndex(initialStorageLimitIndex) + return + if (workspace.plan !== Plan.PRO) { + setSelectedChatsLimitIndex(0) + setSelectedStorageLimitIndex(0) + return + } + setSelectedChatsLimitIndex(workspace.additionalChatsIndex ?? 0) + setSelectedStorageLimitIndex(workspace.additionalStorageIndex ?? 0) }, [ - initialChatsLimitIndex, - initialStorageLimitIndex, selectedChatsLimitIndex, selectedStorageLimitIndex, + workspace.additionalChatsIndex, + workspace.additionalStorageIndex, + workspace?.plan, ]) const workspaceChatsLimit = workspace ? getChatsLimit(workspace) : undefined @@ -81,14 +94,11 @@ export const ProPlanPricingCard = ({ : undefined const isCurrentPlan = - chatsLimit[Plan.PRO].totalIncluded + - chatsLimit[Plan.PRO].increaseStep.amount * - (selectedChatsLimitIndex ?? 0) === - workspaceChatsLimit && - storageLimit[Plan.PRO].totalIncluded + - storageLimit[Plan.PRO].increaseStep.amount * - (selectedStorageLimitIndex ?? 0) === - workspaceStorageLimit + chatsLimit[Plan.PRO].graduatedPrice[selectedChatsLimitIndex ?? 0] + .totalIncluded === workspaceChatsLimit && + storageLimit[Plan.PRO].graduatedPrice[selectedStorageLimitIndex ?? 0] + .totalIncluded === workspaceStorageLimit && + isYearly === currentSubscription?.isYearly const getButtonLabel = () => { if ( @@ -100,8 +110,8 @@ export const ProPlanPricingCard = ({ if (isCurrentPlan) return scopedT('upgradeButton.current') if ( - selectedChatsLimitIndex !== initialChatsLimitIndex || - selectedStorageLimitIndex !== initialStorageLimitIndex + selectedChatsLimitIndex !== workspace.additionalChatsIndex || + selectedStorageLimitIndex !== workspace.additionalStorageIndex ) return t('update') } @@ -149,7 +159,11 @@ export const ProPlanPricingCard = ({ {scopedT('heading', { - plan: Pro, + plan: ( + + Pro + + ), })} {scopedT('pro.description')} @@ -160,7 +174,8 @@ export const ProPlanPricingCard = ({ computePrice( Plan.PRO, selectedChatsLimitIndex ?? 0, - selectedStorageLimitIndex ?? 0 + selectedStorageLimitIndex ?? 0, + isYearly ? 'yearly' : 'monthly' ) ?? NaN, currency )} @@ -201,50 +216,21 @@ export const ProPlanPricingCard = ({ > {selectedChatsLimitIndex !== undefined ? parseNumberWithCommas( - chatsLimit.PRO.totalIncluded + - chatsLimit.PRO.increaseStep.amount * - selectedChatsLimitIndex + chatsLimit.PRO.graduatedPrice[ + selectedChatsLimitIndex + ].totalIncluded ) : undefined} - {selectedChatsLimitIndex !== 0 && ( - setSelectedChatsLimitIndex(0)}> - {parseNumberWithCommas(chatsLimit.PRO.totalIncluded)} + {chatsLimit.PRO.graduatedPrice.map((price, index) => ( + setSelectedChatsLimitIndex(index)} + > + {parseNumberWithCommas(price.totalIncluded)} - )} - {selectedChatsLimitIndex !== 1 && ( - setSelectedChatsLimitIndex(1)}> - {parseNumberWithCommas( - chatsLimit.PRO.totalIncluded + - chatsLimit.PRO.increaseStep.amount - )} - - )} - {selectedChatsLimitIndex !== 2 && ( - setSelectedChatsLimitIndex(2)}> - {parseNumberWithCommas( - chatsLimit.PRO.totalIncluded + - chatsLimit.PRO.increaseStep.amount * 2 - )} - - )} - {selectedChatsLimitIndex !== 3 && ( - setSelectedChatsLimitIndex(3)}> - {parseNumberWithCommas( - chatsLimit.PRO.totalIncluded + - chatsLimit.PRO.increaseStep.amount * 3 - )} - - )} - {selectedChatsLimitIndex !== 4 && ( - setSelectedChatsLimitIndex(4)}> - {parseNumberWithCommas( - chatsLimit.PRO.totalIncluded + - chatsLimit.PRO.increaseStep.amount * 4 - )} - - )} + ))} {' '} {scopedT('chatsPerMonth')} @@ -262,62 +248,21 @@ export const ProPlanPricingCard = ({ > {selectedStorageLimitIndex !== undefined ? parseNumberWithCommas( - storageLimit.PRO.totalIncluded + - storageLimit.PRO.increaseStep.amount * - selectedStorageLimitIndex + storageLimit.PRO.graduatedPrice[ + selectedStorageLimitIndex + ].totalIncluded ) : undefined} - {selectedStorageLimitIndex !== 0 && ( + {storageLimit.PRO.graduatedPrice.map((price, index) => ( setSelectedStorageLimitIndex(0)} + key={index} + onClick={() => setSelectedStorageLimitIndex(index)} > - {parseNumberWithCommas( - storageLimit.PRO.totalIncluded - )} + {parseNumberWithCommas(price.totalIncluded)} - )} - {selectedStorageLimitIndex !== 1 && ( - setSelectedStorageLimitIndex(1)} - > - {parseNumberWithCommas( - storageLimit.PRO.totalIncluded + - storageLimit.PRO.increaseStep.amount - )} - - )} - {selectedStorageLimitIndex !== 2 && ( - setSelectedStorageLimitIndex(2)} - > - {parseNumberWithCommas( - storageLimit.PRO.totalIncluded + - storageLimit.PRO.increaseStep.amount * 2 - )} - - )} - {selectedStorageLimitIndex !== 3 && ( - setSelectedStorageLimitIndex(3)} - > - {parseNumberWithCommas( - storageLimit.PRO.totalIncluded + - storageLimit.PRO.increaseStep.amount * 3 - )} - - )} - {selectedStorageLimitIndex !== 4 && ( - setSelectedStorageLimitIndex(4)} - > - {parseNumberWithCommas( - storageLimit.PRO.totalIncluded + - storageLimit.PRO.increaseStep.amount * 4 - )} - - )} + ))} {' '} {scopedT('storageLimit')} diff --git a/apps/builder/src/features/billing/components/StarterPlanPricingCard.tsx b/apps/builder/src/features/billing/components/StarterPlanPricingCard.tsx index 3aca68fe6..4a556557f 100644 --- a/apps/builder/src/features/billing/components/StarterPlanPricingCard.tsx +++ b/apps/builder/src/features/billing/components/StarterPlanPricingCard.tsx @@ -11,10 +11,9 @@ import { Text, } from '@chakra-ui/react' import { ChevronLeftIcon } from '@/components/icons' -import { useWorkspace } from '@/features/workspace/WorkspaceProvider' import { Plan } from '@typebot.io/prisma' import { useEffect, useState } from 'react' -import { parseNumberWithCommas } from '@typebot.io/lib' +import { isDefined, parseNumberWithCommas } from '@typebot.io/lib' import { chatsLimit, computePrice, @@ -26,12 +25,23 @@ import { import { FeaturesList } from './FeaturesList' import { MoreInfoTooltip } from '@/components/MoreInfoTooltip' import { useI18n, useScopedI18n } from '@/locales' +import { Workspace } from '@typebot.io/schemas' type Props = { - initialChatsLimitIndex?: number - initialStorageLimitIndex?: number + workspace: Pick< + Workspace, + | 'additionalChatsIndex' + | 'additionalStorageIndex' + | 'plan' + | 'customChatsLimit' + | 'customStorageLimit' + > + currentSubscription: { + isYearly?: boolean + } currency?: 'eur' | 'usd' isLoading?: boolean + isYearly: boolean onPayClick: (props: { selectedChatsLimitIndex: number selectedStorageLimitIndex: number @@ -39,15 +49,15 @@ type Props = { } export const StarterPlanPricingCard = ({ - initialChatsLimitIndex, - initialStorageLimitIndex, + workspace, + currentSubscription, isLoading, currency, + isYearly, onPayClick, }: Props) => { const t = useI18n() const scopedT = useScopedI18n('billing.pricingCard') - const { workspace } = useWorkspace() const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] = useState() const [selectedStorageLimitIndex, setSelectedStorageLimitIndex] = @@ -55,20 +65,23 @@ export const StarterPlanPricingCard = ({ useEffect(() => { if ( - selectedChatsLimitIndex === undefined && - initialChatsLimitIndex !== undefined + isDefined(selectedChatsLimitIndex) || + isDefined(selectedStorageLimitIndex) ) - setSelectedChatsLimitIndex(initialChatsLimitIndex) - if ( - selectedStorageLimitIndex === undefined && - initialStorageLimitIndex !== undefined - ) - setSelectedStorageLimitIndex(initialStorageLimitIndex) + return + if (workspace.plan !== Plan.STARTER) { + setSelectedChatsLimitIndex(0) + setSelectedStorageLimitIndex(0) + return + } + setSelectedChatsLimitIndex(workspace.additionalChatsIndex ?? 0) + setSelectedStorageLimitIndex(workspace.additionalStorageIndex ?? 0) }, [ - initialChatsLimitIndex, - initialStorageLimitIndex, selectedChatsLimitIndex, selectedStorageLimitIndex, + workspace.additionalChatsIndex, + workspace.additionalStorageIndex, + workspace?.plan, ]) const workspaceChatsLimit = workspace ? getChatsLimit(workspace) : undefined @@ -77,14 +90,11 @@ export const StarterPlanPricingCard = ({ : undefined const isCurrentPlan = - chatsLimit[Plan.STARTER].totalIncluded + - chatsLimit[Plan.STARTER].increaseStep.amount * - (selectedChatsLimitIndex ?? 0) === - workspaceChatsLimit && - storageLimit[Plan.STARTER].totalIncluded + - storageLimit[Plan.STARTER].increaseStep.amount * - (selectedStorageLimitIndex ?? 0) === - workspaceStorageLimit + chatsLimit[Plan.STARTER].graduatedPrice[selectedChatsLimitIndex ?? 0] + .totalIncluded === workspaceChatsLimit && + storageLimit[Plan.STARTER].graduatedPrice[selectedStorageLimitIndex ?? 0] + .totalIncluded === workspaceStorageLimit && + isYearly === currentSubscription?.isYearly const getButtonLabel = () => { if ( @@ -97,8 +107,9 @@ export const StarterPlanPricingCard = ({ if (isCurrentPlan) return scopedT('upgradeButton.current') if ( - selectedChatsLimitIndex !== initialChatsLimitIndex || - selectedStorageLimitIndex !== initialStorageLimitIndex + selectedChatsLimitIndex !== workspace.additionalChatsIndex || + selectedStorageLimitIndex !== workspace.additionalStorageIndex || + isYearly !== currentSubscription?.isYearly ) return t('update') } @@ -131,7 +142,8 @@ export const StarterPlanPricingCard = ({ computePrice( Plan.STARTER, selectedChatsLimitIndex ?? 0, - selectedStorageLimitIndex ?? 0 + selectedStorageLimitIndex ?? 0, + isYearly ? 'yearly' : 'monthly' ) ?? NaN, currency )} @@ -151,52 +163,21 @@ export const StarterPlanPricingCard = ({ > {selectedChatsLimitIndex !== undefined ? parseNumberWithCommas( - chatsLimit.STARTER.totalIncluded + - chatsLimit.STARTER.increaseStep.amount * - selectedChatsLimitIndex + chatsLimit.STARTER.graduatedPrice[ + selectedChatsLimitIndex + ].totalIncluded ) : undefined} - {selectedChatsLimitIndex !== 0 && ( - setSelectedChatsLimitIndex(0)}> - {parseNumberWithCommas( - chatsLimit.STARTER.totalIncluded - )} + {chatsLimit.STARTER.graduatedPrice.map((price, index) => ( + setSelectedChatsLimitIndex(index)} + > + {parseNumberWithCommas(price.totalIncluded)} - )} - {selectedChatsLimitIndex !== 1 && ( - setSelectedChatsLimitIndex(1)}> - {parseNumberWithCommas( - chatsLimit.STARTER.totalIncluded + - chatsLimit.STARTER.increaseStep.amount - )} - - )} - {selectedChatsLimitIndex !== 2 && ( - setSelectedChatsLimitIndex(2)}> - {parseNumberWithCommas( - chatsLimit.STARTER.totalIncluded + - chatsLimit.STARTER.increaseStep.amount * 2 - )} - - )} - {selectedChatsLimitIndex !== 3 && ( - setSelectedChatsLimitIndex(3)}> - {parseNumberWithCommas( - chatsLimit.STARTER.totalIncluded + - chatsLimit.STARTER.increaseStep.amount * 3 - )} - - )} - {selectedChatsLimitIndex !== 4 && ( - setSelectedChatsLimitIndex(4)}> - {parseNumberWithCommas( - chatsLimit.STARTER.totalIncluded + - chatsLimit.STARTER.increaseStep.amount * 4 - )} - - )} + ))} {' '} {scopedT('chatsPerMonth')} @@ -214,52 +195,21 @@ export const StarterPlanPricingCard = ({ > {selectedStorageLimitIndex !== undefined ? parseNumberWithCommas( - storageLimit.STARTER.totalIncluded + - storageLimit.STARTER.increaseStep.amount * - selectedStorageLimitIndex + storageLimit.STARTER.graduatedPrice[ + selectedStorageLimitIndex + ].totalIncluded ) : undefined} - {selectedStorageLimitIndex !== 0 && ( - setSelectedStorageLimitIndex(0)}> - {parseNumberWithCommas( - storageLimit.STARTER.totalIncluded - )} + {storageLimit.STARTER.graduatedPrice.map((price, index) => ( + setSelectedStorageLimitIndex(index)} + > + {parseNumberWithCommas(price.totalIncluded)} - )} - {selectedStorageLimitIndex !== 1 && ( - setSelectedStorageLimitIndex(1)}> - {parseNumberWithCommas( - storageLimit.STARTER.totalIncluded + - storageLimit.STARTER.increaseStep.amount - )} - - )} - {selectedStorageLimitIndex !== 2 && ( - setSelectedStorageLimitIndex(2)}> - {parseNumberWithCommas( - storageLimit.STARTER.totalIncluded + - storageLimit.STARTER.increaseStep.amount * 2 - )} - - )} - {selectedStorageLimitIndex !== 3 && ( - setSelectedStorageLimitIndex(3)}> - {parseNumberWithCommas( - storageLimit.STARTER.totalIncluded + - storageLimit.STARTER.increaseStep.amount * 3 - )} - - )} - {selectedStorageLimitIndex !== 4 && ( - setSelectedStorageLimitIndex(4)}> - {parseNumberWithCommas( - storageLimit.STARTER.totalIncluded + - storageLimit.STARTER.increaseStep.amount * 4 - )} - - )} + ))} {' '} {scopedT('storageLimit')} diff --git a/apps/builder/src/features/billing/helpers/parseSubscriptionItems.ts b/apps/builder/src/features/billing/helpers/parseSubscriptionItems.ts index ec310dbd6..6ef05ab1e 100644 --- a/apps/builder/src/features/billing/helpers/parseSubscriptionItems.ts +++ b/apps/builder/src/features/billing/helpers/parseSubscriptionItems.ts @@ -1,16 +1,19 @@ -import { Plan } from '@typebot.io/prisma' +import { + getChatsLimit, + getStorageLimit, + priceIds, +} from '@typebot.io/lib/pricing' export const parseSubscriptionItems = ( - plan: Plan, + plan: 'STARTER' | 'PRO', additionalChats: number, - additionalStorage: number -) => - [ + additionalStorage: number, + isYearly: boolean +) => { + const frequency = isYearly ? 'yearly' : 'monthly' + return [ { - price: - plan === Plan.STARTER - ? process.env.STRIPE_STARTER_PRICE_ID - : process.env.STRIPE_PRO_PRICE_ID, + price: priceIds[plan].base[frequency], quantity: 1, }, ] @@ -18,8 +21,12 @@ export const parseSubscriptionItems = ( additionalChats > 0 ? [ { - price: process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID, - quantity: additionalChats, + price: priceIds[plan].chats[frequency], + quantity: getChatsLimit({ + plan, + additionalChatsIndex: additionalChats, + customChatsLimit: null, + }), }, ] : [] @@ -28,9 +35,14 @@ export const parseSubscriptionItems = ( additionalStorage > 0 ? [ { - price: process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID, - quantity: additionalStorage, + price: priceIds[plan].storage[frequency], + quantity: getStorageLimit({ + plan, + additionalStorageIndex: additionalStorage, + customStorageLimit: null, + }), }, ] : [] ) +} diff --git a/apps/builder/src/features/dashboard/components/DashboardPage.tsx b/apps/builder/src/features/dashboard/components/DashboardPage.tsx index 7d5ef40ad..20cef5e07 100644 --- a/apps/builder/src/features/dashboard/components/DashboardPage.tsx +++ b/apps/builder/src/features/dashboard/components/DashboardPage.tsx @@ -26,10 +26,11 @@ export const DashboardPage = () => { useState() useEffect(() => { - const { subscribePlan, chats, storage } = query as { + const { subscribePlan, chats, storage, isYearly } = query as { subscribePlan: Plan | undefined chats: string | undefined storage: string | undefined + isYearly: string | undefined } if (workspace && subscribePlan && user && workspace.plan === 'FREE') { setIsLoading(true) @@ -39,6 +40,7 @@ export const DashboardPage = () => { additionalChats: chats ? parseInt(chats) : 0, additionalStorage: storage ? parseInt(storage) : 0, currency: guessIfUserIsEuropean() ? 'eur' : 'usd', + isYearly: isYearly === 'false' ? false : true, }) } }, [query, user, workspace]) diff --git a/apps/builder/src/locales/en.ts b/apps/builder/src/locales/en.ts index e9592d9b3..4a49c0a20 100644 --- a/apps/builder/src/locales/en.ts +++ b/apps/builder/src/locales/en.ts @@ -103,8 +103,6 @@ export default { 'billing.currentSubscription.heading': 'Subscription', 'billing.currentSubscription.subheading': 'Current workspace subscription:', 'billing.currentSubscription.cancelLink': 'Cancel my subscription', - 'billing.currentSubscription.billingPortalDescription': - 'Need to change payment method or billing information? Head over to your billing portal:', 'billing.invoices.heading': 'Invoices', 'billing.invoices.empty': 'No invoices found for this workspace.', 'billing.invoices.paidAt': 'Paid at', diff --git a/apps/builder/src/locales/fr.ts b/apps/builder/src/locales/fr.ts index 3a1a401f6..4db878167 100644 --- a/apps/builder/src/locales/fr.ts +++ b/apps/builder/src/locales/fr.ts @@ -109,8 +109,6 @@ export default defineLocale({ 'billing.currentSubscription.heading': 'Abonnement', 'billing.currentSubscription.subheading': 'Abonnement actuel du workspace :', 'billing.currentSubscription.cancelLink': "Annuler l'abonnement", - 'billing.currentSubscription.billingPortalDescription': - 'Besoin de changer votre mode de paiement ou vos informations de facturation ? Rendez-vous sur votre portail de facturation :', 'billing.invoices.heading': 'Factures', 'billing.invoices.empty': 'Aucune facture trouvée pour ce workspace.', 'billing.invoices.paidAt': 'Payé le', diff --git a/apps/builder/src/locales/pt.ts b/apps/builder/src/locales/pt.ts index df3c7aa18..4fe6cff14 100644 --- a/apps/builder/src/locales/pt.ts +++ b/apps/builder/src/locales/pt.ts @@ -109,8 +109,6 @@ export default defineLocale({ 'billing.currentSubscription.subheading': 'Assinatura atual do espaço de trabalho:', 'billing.currentSubscription.cancelLink': 'Cancelar minha assinatura', - 'billing.currentSubscription.billingPortalDescription': - 'Precisa alterar o método de pagamento ou as informações de cobrança? Acesse seu portal de cobrança:', 'billing.invoices.heading': 'Faturas', 'billing.invoices.empty': 'Nenhuma fatura encontrada para este espaço de trabalho.', diff --git a/apps/builder/src/test/utils/databaseActions.ts b/apps/builder/src/test/utils/databaseActions.ts index 1370d4a10..d964cb6eb 100644 --- a/apps/builder/src/test/utils/databaseActions.ts +++ b/apps/builder/src/test/utils/databaseActions.ts @@ -52,6 +52,19 @@ export const addSubscriptionToWorkspace = async ( ...metadata, }, }) + return stripeId +} + +export const cancelSubscription = async (stripeId: string) => { + const currentSubscriptionId = ( + await stripe.subscriptions.list({ + customer: stripeId, + }) + ).data.shift()?.id + if (currentSubscriptionId) + await stripe.subscriptions.update(currentSubscriptionId, { + cancel_at_period_end: true, + }) } export const createCollaboration = ( diff --git a/apps/docs/docs/self-hosting/configuration/builder.mdx b/apps/docs/docs/self-hosting/configuration/builder.mdx index 5ae5cfc6a..b175fe0e2 100644 --- a/apps/docs/docs/self-hosting/configuration/builder.mdx +++ b/apps/docs/docs/self-hosting/configuration/builder.mdx @@ -201,15 +201,26 @@ 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_PRO_PRICE_ID | | Pro plan price id | -| STRIPE_STARTER_PRICE_ID | | Starter plan price id | -| STRIPE_ADDITIONAL_CHATS_PRICE_ID | | Additional chats price id | -| STRIPE_ADDITIONAL_STORAGE_PRICE_ID | | Additional storage 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_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_ADDITIONAL_STORAGE_PRICE_ID | | Additional storage 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 00cc20a07..7f900d931 100644 --- a/apps/docs/openapi/builder/_spec_.json +++ b/apps/docs/openapi/builder/_spec_.json @@ -4382,10 +4382,10 @@ } } }, - "/billing/subscription": { - "delete": { - "operationId": "mutation.billing.cancelSubscription", - "summary": "Cancel current subscription", + "/billing/subscription/checkout": { + "post": { + "operationId": "mutation.billing.createCheckoutSession", + "summary": "Create checkout session to create a new subscription", "tags": [ "Billing" ], @@ -4394,16 +4394,85 @@ "Authorization": [] } ], - "parameters": [ - { - "name": "workspaceId", - "in": "query", - "required": true, - "schema": { - "type": "string" + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "company": { + "type": "string" + }, + "workspaceId": { + "type": "string" + }, + "prefilledEmail": { + "type": "string" + }, + "currency": { + "type": "string", + "enum": [ + "usd", + "eur" + ] + }, + "plan": { + "type": "string", + "enum": [ + "STARTER", + "PRO" + ] + }, + "returnUrl": { + "type": "string" + }, + "additionalChats": { + "type": "number" + }, + "additionalStorage": { + "type": "number" + }, + "vat": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "type", + "value" + ], + "additionalProperties": false + }, + "isYearly": { + "type": "boolean" + } + }, + "required": [ + "email", + "company", + "workspaceId", + "currency", + "plan", + "returnUrl", + "additionalChats", + "additionalStorage", + "isYearly" + ], + "additionalProperties": false + } } } - ], + }, + "parameters": [], "responses": { "200": { "description": "Successful response", @@ -4412,15 +4481,12 @@ "schema": { "type": "object", "properties": { - "message": { - "type": "string", - "enum": [ - "success" - ] + "checkoutUrl": { + "type": "string" } }, "required": [ - "message" + "checkoutUrl" ], "additionalProperties": false } @@ -4431,7 +4497,9 @@ "$ref": "#/components/responses/error" } } - }, + } + }, + "/billing/subscription": { "patch": { "operationId": "mutation.billing.updateSubscription", "summary": "Update subscription", @@ -4472,6 +4540,9 @@ "usd", "eur" ] + }, + "isYearly": { + "type": "boolean" } }, "required": [ @@ -4479,7 +4550,8 @@ "plan", "additionalChats", "additionalStorage", - "currency" + "currency", + "isYearly" ], "additionalProperties": false } @@ -4635,28 +4707,38 @@ "type": "object", "properties": { "subscription": { - "type": "object", - "properties": { - "additionalChatsIndex": { - "type": "number" + "anyOf": [ + { + "type": "object", + "properties": { + "isYearly": { + "type": "boolean" + }, + "currency": { + "type": "string", + "enum": [ + "eur", + "usd" + ] + }, + "cancelDate": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "isYearly", + "currency" + ], + "additionalProperties": false }, - "additionalStorageIndex": { - "type": "number" - }, - "currency": { - "type": "string", + { "enum": [ - "eur", - "usd" - ] + "null" + ], + "nullable": true } - }, - "required": [ - "additionalChatsIndex", - "additionalStorageIndex", - "currency" - ], - "additionalProperties": false + ] } }, "required": [ @@ -4673,119 +4755,6 @@ } } }, - "/billing/subscription/checkout": { - "post": { - "operationId": "mutation.billing.createCheckoutSession", - "summary": "Create checkout session to create a new subscription", - "tags": [ - "Billing" - ], - "security": [ - { - "Authorization": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "email": { - "type": "string" - }, - "company": { - "type": "string" - }, - "workspaceId": { - "type": "string" - }, - "prefilledEmail": { - "type": "string" - }, - "currency": { - "type": "string", - "enum": [ - "usd", - "eur" - ] - }, - "plan": { - "type": "string", - "enum": [ - "STARTER", - "PRO" - ] - }, - "returnUrl": { - "type": "string" - }, - "additionalChats": { - "type": "number" - }, - "additionalStorage": { - "type": "number" - }, - "vat": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "value": { - "type": "string" - } - }, - "required": [ - "type", - "value" - ], - "additionalProperties": false - } - }, - "required": [ - "email", - "company", - "workspaceId", - "currency", - "plan", - "returnUrl", - "additionalChats", - "additionalStorage" - ], - "additionalProperties": false - } - } - } - }, - "parameters": [], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "checkoutUrl": { - "type": "string" - } - }, - "required": [ - "checkoutUrl" - ], - "additionalProperties": false - } - } - } - }, - "default": { - "$ref": "#/components/responses/error" - } - } - } - }, "/billing/usage": { "get": { "operationId": "query.billing.getUsage", diff --git a/apps/landing-page/assets/icons/Logo.tsx b/apps/landing-page/assets/icons/Logo.tsx index 7b7f2b352..4516e0cc8 100644 --- a/apps/landing-page/assets/icons/Logo.tsx +++ b/apps/landing-page/assets/icons/Logo.tsx @@ -2,41 +2,36 @@ import Icon, { IconProps } from '@chakra-ui/icon' import React from 'react' export const Logo = (props: IconProps) => ( - - + + diff --git a/apps/landing-page/components/Homepage/Hero/Hero.tsx b/apps/landing-page/components/Homepage/Hero/Hero.tsx index 2ac067636..01157ba56 100755 --- a/apps/landing-page/components/Homepage/Hero/Hero.tsx +++ b/apps/landing-page/components/Homepage/Hero/Hero.tsx @@ -33,7 +33,7 @@ export const Hero = () => { bgClip="text" data-aos="fade-up" > - Open-source conversational forms builder + Build advanced chatbots visually { textAlign="center" data-aos="fade" > - Introducing Conversational Apps + Replace your old school forms with chatbots ( + + + Enterprise + + Ideal for large companies looking to generate leads and automate + customer support at scale + + + + + + + + + + Custom chats limits & seats for all your team + + + + SSO & Granular access rights + + + + Yearly contract with dedicated support representative + + + + +) diff --git a/apps/landing-page/components/PricingPage/Faq.tsx b/apps/landing-page/components/PricingPage/Faq.tsx new file mode 100644 index 000000000..1037e3af2 --- /dev/null +++ b/apps/landing-page/components/PricingPage/Faq.tsx @@ -0,0 +1,86 @@ +import { Heading, VStack, SimpleGrid, Stack, Text } 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? + + + When you exceed the number of chats 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 run. If you continue + to exceed the limit, you will be kindly asked you to upgrade your + subscription. + + + + + 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. +
+
+
+
+) diff --git a/apps/landing-page/components/PricingPage/FreePlanCard.tsx b/apps/landing-page/components/PricingPage/FreePlanCard.tsx index 42f652e6c..7c4001ce3 100644 --- a/apps/landing-page/components/PricingPage/FreePlanCard.tsx +++ b/apps/landing-page/components/PricingPage/FreePlanCard.tsx @@ -3,6 +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' export const FreePlanCard = () => ( ( 'Unlimited typebots', <> - 300 chats included + + {chatsLimit.FREE.totalIncluded} + {' '} + chats/month   ( as={Link} href="https://app.typebot.io/register" variant="outline" - colorScheme="blue" + colorScheme="gray" size="lg" w="full" fontWeight="extrabold" diff --git a/apps/landing-page/components/PricingPage/PlanComparisonTables.tsx b/apps/landing-page/components/PricingPage/PlanComparisonTables.tsx index 884cc4c35..fe6ec5a05 100644 --- a/apps/landing-page/components/PricingPage/PlanComparisonTables.tsx +++ b/apps/landing-page/components/PricingPage/PlanComparisonTables.tsx @@ -8,7 +8,6 @@ import { Td, Text, Stack, - StackProps, HStack, Tooltip, chakra, @@ -19,367 +18,383 @@ import { CheckIcon } from 'assets/icons/CheckIcon' import { HelpCircleIcon } from 'assets/icons/HelpCircleIcon' import { Plan } from '@typebot.io/prisma' import Link from 'next/link' -import React, { useEffect, useState } from 'react' -import { chatsLimit, formatPrice, storageLimit } from '@typebot.io/lib/pricing' +import React from 'react' +import { + chatsLimit, + formatPrice, + prices, + seatsLimit, + storageLimit, +} from '@typebot.io/lib/pricing' +import { parseNumberWithCommas } from '@typebot.io/lib' -type Props = { - starterPrice: string - proPrice: string -} & StackProps - -export const PlanComparisonTables = ({ - starterPrice, - proPrice, - ...props -}: Props) => { - const [additionalChatsPrice, setAdditionalChatsPrice] = useState( - `$${chatsLimit.STARTER.increaseStep.price}` - ) - const [additionalStoragePrice, setAdditionalStoragePrice] = useState( - `$${storageLimit.STARTER.increaseStep.price}` - ) - - useEffect(() => { - setAdditionalChatsPrice(formatPrice(chatsLimit.STARTER.increaseStep.price)) - setAdditionalStoragePrice( - formatPrice(storageLimit.STARTER.increaseStep.price) - ) - }, []) - - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Usage - FreeStarterPro
Total botsUnlimitedUnlimitedUnlimited
Chats300 / month2,000 / month10,000 / month
Additional Chats - {additionalChatsPrice} per 500{additionalChatsPrice} per 1,000
Storage - 2 GB10 GB
Additional Storage - {additionalStoragePrice} per 1 GB{additionalStoragePrice} per 1 GB
MembersJust you2 seats5 seats
GuestsUnlimitedUnlimitedUnlimited
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Features - FreeStarterPro
- - - - - -
Starter templates - - - - - -
Webhooks - - - - - -
Google Sheets - - - - - -
Google Analytics - - - - - -
Send emails - - - - - -
Zapier - - - - - -
Pabbly Connect - - - - - -
Make.com - - - - - -
Custom Javascript & CSS - - - - - -
Export CSV - - - - - -
File upload inputs - - - - -
- UnlimitedUnlimited
Remove branding - - - - -
Custom domains - - Unlimited
- - - -
-
- - - - - - - - - - - - - - - - - - - - -
- Support - FreeStarterPro
Priority support - - - -
Feature request priority - - - -
-
- - - - Personal - - Free - - - - - - - Starter - - - {starterPrice} / month - - - - - - - - Pro - - - {proPrice} / month - - - - - +export const PlanComparisonTables = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Usage + FreeStarterPro
Total botsUnlimitedUnlimitedUnlimited
Chats{chatsLimit.FREE.totalIncluded} / month + {parseNumberWithCommas( + chatsLimit.STARTER.graduatedPrice[0].totalIncluded + )}{' '} + / month + + {parseNumberWithCommas( + chatsLimit.PRO.graduatedPrice[0].totalIncluded + )}{' '} + / month +
Additional 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} +
Storage + 2 GB10 GB
Additional Storage + + {formatPrice(storageLimit.STARTER.graduatedPrice[1].price)} per{' '} + {storageLimit.STARTER.graduatedPrice[1].totalIncluded - + storageLimit.STARTER.graduatedPrice[0].totalIncluded}{' '} + GB + + {formatPrice(storageLimit.PRO.graduatedPrice[1].price)} per{' '} + {storageLimit.PRO.graduatedPrice[1].totalIncluded - + storageLimit.PRO.graduatedPrice[0].totalIncluded}{' '} + GB +
MembersJust you{seatsLimit.STARTER.totalIncluded} seats{seatsLimit.PRO.totalIncluded} seats
GuestsUnlimitedUnlimitedUnlimited
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Features + FreeStarterPro
+ + + + + +
Starter templates + + + + + +
Webhooks + + + + + +
Google Sheets + + + + + +
Google Analytics + + + + + +
Send emails + + + + + +
Zapier + + + + + +
Pabbly Connect + + + + + +
Make.com + + + + + +
Custom Javascript & CSS + + + + + +
Export CSV + + + + + +
File upload inputs + + + + +
+ UnlimitedUnlimited
Remove branding + + + + +
Custom domains + + Unlimited
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + +
+ Support + FreeStarterPro
Priority support + + + +
Feature request priority + + + +
+
+ + + + Personal + + Free + + + + + + + Starter + + + {formatPrice(prices.STARTER)}{' '} + / month + + + + + + + + Pro + + + {formatPrice(prices.PRO)}{' '} + / month + + + + - ) -} +
+) const TdWithTooltip = ({ text, diff --git a/apps/landing-page/components/PricingPage/PricingCard/index.tsx b/apps/landing-page/components/PricingPage/PricingCard/index.tsx index 01eb162f2..3bda0a7d2 100644 --- a/apps/landing-page/components/PricingPage/PricingCard/index.tsx +++ b/apps/landing-page/components/PricingPage/PricingCard/index.tsx @@ -9,7 +9,6 @@ import { VStack, } from '@chakra-ui/react' import * as React from 'react' -import { useEffect, useState } from 'react' import { formatPrice } from '@typebot.io/lib/pricing' import { CheckCircleIcon } from '../../../assets/icons/CheckCircleIcon' import { Card, CardProps } from './Card' @@ -34,12 +33,8 @@ export const PricingCard = ({ ...rest }: PricingCardProps) => { const { features, price, name } = data - const [formattedPrice, setFormattedPrice] = useState(price) const accentColor = useColorModeValue('blue.500', 'white') - - useEffect(() => { - setFormattedPrice(typeof price === 'number' ? formatPrice(price) : price) - }, [price]) + const formattedPrice = typeof price === 'number' ? formatPrice(price) : price return ( diff --git a/apps/landing-page/components/PricingPage/ProPlanCard.tsx b/apps/landing-page/components/PricingPage/ProPlanCard.tsx index 7d7b472fd..64ffc00f0 100644 --- a/apps/landing-page/components/PricingPage/ProPlanCard.tsx +++ b/apps/landing-page/components/PricingPage/ProPlanCard.tsx @@ -13,28 +13,33 @@ 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, { useEffect, useState } from 'react' +import React, { useState } from 'react' import { parseNumberWithCommas } from '@typebot.io/lib' -import { chatsLimit, computePrice, storageLimit } from '@typebot.io/lib/pricing' +import { + chatsLimit, + computePrice, + seatsLimit, + storageLimit, +} from '@typebot.io/lib/pricing' import { PricingCard } from './PricingCard' -export const ProPlanCard = () => { - const [price, setPrice] = useState(89) +type Props = { + isYearly: boolean +} +export const ProPlanCard = ({ isYearly }: Props) => { const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] = useState(0) const [selectedStorageLimitIndex, setSelectedStorageLimitIndex] = useState(0) - useEffect(() => { - setPrice( - computePrice( - Plan.PRO, - selectedChatsLimitIndex ?? 0, - selectedStorageLimitIndex ?? 0 - ) ?? NaN - ) - }, [selectedChatsLimitIndex, selectedStorageLimitIndex]) + const price = + computePrice( + Plan.PRO, + selectedChatsLimitIndex ?? 0, + selectedStorageLimitIndex ?? 0, + isYearly ? 'yearly' : 'monthly' + ) ?? NaN return ( { featureLabel: 'Everything in Personal, plus:', features: [ - 5 seats included + + {seatsLimit.PRO.totalIncluded} seats + {' '} + included , @@ -57,50 +65,20 @@ export const ProPlanCard = () => { > {selectedChatsLimitIndex !== undefined ? parseNumberWithCommas( - chatsLimit.PRO.totalIncluded + - chatsLimit.PRO.increaseStep.amount * - selectedChatsLimitIndex + chatsLimit.PRO.graduatedPrice[selectedChatsLimitIndex] + .totalIncluded ) : undefined} - {selectedChatsLimitIndex !== 0 && ( - setSelectedChatsLimitIndex(0)}> - {parseNumberWithCommas(chatsLimit.PRO.totalIncluded)} + {chatsLimit.PRO.graduatedPrice.map((price, index) => ( + setSelectedChatsLimitIndex(index)} + > + {parseNumberWithCommas(price.totalIncluded)} - )} - {selectedChatsLimitIndex !== 1 && ( - setSelectedChatsLimitIndex(1)}> - {parseNumberWithCommas( - chatsLimit.PRO.totalIncluded + - chatsLimit.PRO.increaseStep.amount - )} - - )} - {selectedChatsLimitIndex !== 2 && ( - setSelectedChatsLimitIndex(2)}> - {parseNumberWithCommas( - chatsLimit.PRO.totalIncluded + - chatsLimit.PRO.increaseStep.amount * 2 - )} - - )} - {selectedChatsLimitIndex !== 3 && ( - setSelectedChatsLimitIndex(3)}> - {parseNumberWithCommas( - chatsLimit.PRO.totalIncluded + - chatsLimit.PRO.increaseStep.amount * 3 - )} - - )} - {selectedChatsLimitIndex !== 4 && ( - setSelectedChatsLimitIndex(4)}> - {parseNumberWithCommas( - chatsLimit.PRO.totalIncluded + - chatsLimit.PRO.increaseStep.amount * 4 - )} - - )} + ))} {' '} chats/mo @@ -126,50 +104,20 @@ export const ProPlanCard = () => { > {selectedStorageLimitIndex !== undefined ? parseNumberWithCommas( - storageLimit.PRO.totalIncluded + - storageLimit.PRO.increaseStep.amount * - selectedStorageLimitIndex + storageLimit.PRO.graduatedPrice[selectedStorageLimitIndex] + .totalIncluded ) : undefined} - {selectedStorageLimitIndex !== 0 && ( - setSelectedStorageLimitIndex(0)}> - {parseNumberWithCommas(storageLimit.PRO.totalIncluded)} + {storageLimit.PRO.graduatedPrice.map((price, index) => ( + setSelectedStorageLimitIndex(index)} + > + {parseNumberWithCommas(price.totalIncluded)} - )} - {selectedStorageLimitIndex !== 1 && ( - setSelectedStorageLimitIndex(1)}> - {parseNumberWithCommas( - storageLimit.PRO.totalIncluded + - storageLimit.PRO.increaseStep.amount - )} - - )} - {selectedStorageLimitIndex !== 2 && ( - setSelectedStorageLimitIndex(2)}> - {parseNumberWithCommas( - storageLimit.PRO.totalIncluded + - storageLimit.PRO.increaseStep.amount * 2 - )} - - )} - {selectedStorageLimitIndex !== 3 && ( - setSelectedStorageLimitIndex(3)}> - {parseNumberWithCommas( - storageLimit.PRO.totalIncluded + - storageLimit.PRO.increaseStep.amount * 3 - )} - - )} - {selectedStorageLimitIndex !== 4 && ( - setSelectedStorageLimitIndex(4)}> - {parseNumberWithCommas( - storageLimit.PRO.totalIncluded + - storageLimit.PRO.increaseStep.amount * 4 - )} - - )} + ))} {' '} GB of storage @@ -194,7 +142,7 @@ export const ProPlanCard = () => { button={ diff --git a/apps/landing-page/pages/pricing.tsx b/apps/landing-page/pages/pricing.tsx index c2f79998b..f091f0b4c 100644 --- a/apps/landing-page/pages/pricing.tsx +++ b/apps/landing-page/pages/pricing.tsx @@ -1,39 +1,30 @@ import { - Accordion, - AccordionButton, - AccordionIcon, - AccordionItem, - AccordionPanel, DarkMode, Flex, Stack, - Box, Heading, VStack, Text, HStack, + Switch, + Tag, } from '@chakra-ui/react' import { Footer } from 'components/common/Footer' import { Header } from 'components/common/Header/Header' import { SocialMetaTags } from 'components/common/SocialMetaTags' import { BackgroundPolygons } from 'components/Homepage/Hero/BackgroundPolygons' import { PlanComparisonTables } from 'components/PricingPage/PlanComparisonTables' -import { useEffect, useState } from 'react' -import { formatPrice, prices } from '@typebot.io/lib/pricing' +import { useState } from 'react' import { StripeClimateLogo } from 'assets/logos/StripeClimateLogo' import { FreePlanCard } from 'components/PricingPage/FreePlanCard' import { StarterPlanCard } from 'components/PricingPage/StarterPlanCard' import { ProPlanCard } from 'components/PricingPage/ProPlanCard' import { TextLink } from 'components/common/TextLink' +import { EnterprisePlanCard } from 'components/PricingPage/EnterprisePlanCard' +import { Faq } from 'components/PricingPage/Faq' const Pricing = () => { - const [starterPrice, setStarterPrice] = useState('$39') - const [proPrice, setProPrice] = useState('$89') - - useEffect(() => { - setStarterPrice(formatPrice(prices.STARTER)) - setProPrice(formatPrice(prices.PRO)) - }, []) + const [isYearly, setIsYearly] = useState(true) return ( @@ -52,20 +43,31 @@ const Pricing = () => { - + - Plans fit for you - + + Plans fit for you + + Whether you're a{' '} solo business owner - {' '} - or a{' '} + + , a{' '} growing startup + {' '} + or a{' '} + + large company - , Typebot is here to help you build high-performing bots for the - right price. Pay for as little or as much usage as you need. + , Typebot is here to help you build high-performing chat forms + for the right price. Pay for as little or as much usage as you + need. @@ -85,38 +87,41 @@ const Pricing = () => {
- - - - + + + Monthly + setIsYearly(!isYearly)} + /> + + Yearly + 16% off + + + + + + + + - - Need custom limits? Specific features?{' '} - - Let's chat! - - + + Compare plans & features - + - - Frequently asked questions - - + @@ -125,75 +130,4 @@ const Pricing = () => { ) } -const Faq = () => { - return ( - - - - - - What happens once I reach the monthly chats limit? - - - - - - You will receive an email notification once you reached 80% of this - limit. Then, once you reach 100%, the bot will be closed to new users. - Upgrading your limit will automatically reopen the bot. - - - - - - - What happens once I reach the storage limit? - - - - - - You will receive an email notification once you reached 80% of this - limit. Then, once you reach 100%, your users will still be able to - chat with your bot but their uploads won't be stored anymore. You - will need to upgrade the limit or free up some space to continue - collecting your users' files. - - - - - - - Why is there no trial? - - - - - - For now, Typebot offers a Freemium based business model. My goal is to - make sure you have time to create awesome bots and collect valuable - results. If you need advanced features then you can upgrade any time. - - - - - - - If I change my mind, can I get a refund? - - - - - - Sure! Just{' '} - - shoot me an email - {' '} - and we'll figure things out 😀 - - - - ) -} - export default Pricing diff --git a/packages/lib/pricing.ts b/packages/lib/pricing.ts index 2e8b39554..77391ee68 100644 --- a/packages/lib/pricing.ts +++ b/packages/lib/pricing.ts @@ -3,26 +3,68 @@ import { Plan } from '@typebot.io/prisma' const infinity = -1 +export const priceIds = { + [Plan.STARTER]: { + base: { + monthly: process.env.STRIPE_STARTER_MONTHLY_PRICE_ID, + yearly: process.env.STRIPE_STARTER_YEARLY_PRICE_ID, + }, + chats: { + monthly: process.env.STRIPE_STARTER_CHATS_MONTHLY_PRICE_ID, + yearly: process.env.STRIPE_STARTER_CHATS_YEARLY_PRICE_ID, + }, + storage: { + monthly: process.env.STRIPE_STARTER_STORAGE_MONTHLY_PRICE_ID, + yearly: process.env.STRIPE_STARTER_STORAGE_YEARLY_PRICE_ID, + }, + }, + [Plan.PRO]: { + base: { + monthly: process.env.STRIPE_PRO_MONTHLY_PRICE_ID, + yearly: process.env.STRIPE_PRO_YEARLY_PRICE_ID, + }, + chats: { + monthly: process.env.STRIPE_PRO_CHATS_MONTHLY_PRICE_ID, + yearly: process.env.STRIPE_PRO_CHATS_YEARLY_PRICE_ID, + }, + storage: { + monthly: process.env.STRIPE_PRO_STORAGE_MONTHLY_PRICE_ID, + yearly: process.env.STRIPE_PRO_STORAGE_YEARLY_PRICE_ID, + }, + }, +} + export const prices = { [Plan.STARTER]: 39, [Plan.PRO]: 89, } as const export const chatsLimit = { - [Plan.FREE]: { totalIncluded: 300 }, + [Plan.FREE]: { totalIncluded: 200 }, [Plan.STARTER]: { - totalIncluded: 2000, - increaseStep: { - amount: 500, - price: 10, - }, + graduatedPrice: [ + { totalIncluded: 2000, price: 0 }, + { + totalIncluded: 2500, + price: 10, + }, + { + totalIncluded: 3000, + price: 20, + }, + { + totalIncluded: 3500, + price: 30, + }, + ], }, [Plan.PRO]: { - totalIncluded: 10000, - increaseStep: { - amount: 1000, - price: 10, - }, + graduatedPrice: [ + { totalIncluded: 10000, price: 0 }, + { totalIncluded: 15000, price: 50 }, + { totalIncluded: 25000, price: 150 }, + { totalIncluded: 50000, price: 400 }, + ], }, [Plan.CUSTOM]: { totalIncluded: 2000, @@ -39,18 +81,38 @@ export const chatsLimit = { export const storageLimit = { [Plan.FREE]: { totalIncluded: 0 }, [Plan.STARTER]: { - totalIncluded: 2, - increaseStep: { - amount: 1, - price: 2, - }, + graduatedPrice: [ + { totalIncluded: 2, price: 0 }, + { + totalIncluded: 3, + price: 2, + }, + { + totalIncluded: 4, + price: 4, + }, + { + totalIncluded: 5, + price: 6, + }, + ], }, [Plan.PRO]: { - totalIncluded: 10, - increaseStep: { - amount: 1, - price: 2, - }, + graduatedPrice: [ + { totalIncluded: 10, price: 0 }, + { + totalIncluded: 15, + price: 8, + }, + { + totalIncluded: 25, + price: 24, + }, + { + totalIncluded: 40, + price: 49, + }, + ], }, [Plan.CUSTOM]: { totalIncluded: 2, @@ -86,13 +148,12 @@ export const getChatsLimit = ({ customChatsLimit, }: Pick) => { if (customChatsLimit) return customChatsLimit - const { totalIncluded } = chatsLimit[plan] - const increaseStep = + const totalIncluded = plan === Plan.STARTER || plan === Plan.PRO - ? chatsLimit[plan].increaseStep - : { amount: 0 } + ? chatsLimit[plan].graduatedPrice[additionalChatsIndex].totalIncluded + : chatsLimit[plan].totalIncluded if (totalIncluded === infinity) return infinity - return totalIncluded + increaseStep.amount * additionalChatsIndex + return totalIncluded } export const getStorageLimit = ({ @@ -104,12 +165,11 @@ export const getStorageLimit = ({ 'additionalStorageIndex' | 'plan' | 'customStorageLimit' >) => { if (customStorageLimit) return customStorageLimit - const { totalIncluded } = storageLimit[plan] - const increaseStep = + const totalIncluded = plan === Plan.STARTER || plan === Plan.PRO - ? storageLimit[plan].increaseStep - : { amount: 0 } - return totalIncluded + increaseStep.amount * additionalStorageIndex + ? storageLimit[plan].graduatedPrice[additionalStorageIndex].totalIncluded + : storageLimit[plan].totalIncluded + return totalIncluded } export const getSeatsLimit = ({ @@ -139,20 +199,15 @@ export const isSeatsLimitReached = ({ export const computePrice = ( plan: Plan, selectedTotalChatsIndex: number, - selectedTotalStorageIndex: number + selectedTotalStorageIndex: number, + frequency: 'monthly' | 'yearly' ) => { if (plan !== Plan.STARTER && plan !== Plan.PRO) return - const { - increaseStep: { price: chatsPrice }, - } = chatsLimit[plan] - const { - increaseStep: { price: storagePrice }, - } = storageLimit[plan] - return ( + const price = prices[plan] + - selectedTotalChatsIndex * chatsPrice + - selectedTotalStorageIndex * storagePrice - ) + chatsLimit[plan].graduatedPrice[selectedTotalChatsIndex].price + + storageLimit[plan].graduatedPrice[selectedTotalStorageIndex].price + return frequency === 'monthly' ? price : price - price * 0.16 } const europeanUnionCountryCodes = [ @@ -202,13 +257,15 @@ const europeanUnionExclusiveLanguageCodes = [ 'bg', ] -export const guessIfUserIsEuropean = () => - window.navigator.languages.some((language) => { +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() diff --git a/packages/schemas/features/billing/subscription.ts b/packages/schemas/features/billing/subscription.ts index 2fa571206..f24544906 100644 --- a/packages/schemas/features/billing/subscription.ts +++ b/packages/schemas/features/billing/subscription.ts @@ -1,9 +1,9 @@ import { z } from 'zod' export const subscriptionSchema = z.object({ - additionalChatsIndex: z.number(), - additionalStorageIndex: z.number(), + isYearly: z.boolean(), currency: z.enum(['eur', 'usd']), + cancelDate: z.date().optional(), }) export type Subscription = z.infer