diff --git a/apps/builder/src/features/billing/components/BillingContent/UsageContent/UsageContent.tsx b/apps/builder/src/features/billing/components/BillingContent/UsageContent/UsageContent.tsx
index 0cc8122dd..119075e6f 100644
--- a/apps/builder/src/features/billing/components/BillingContent/UsageContent/UsageContent.tsx
+++ b/apps/builder/src/features/billing/components/BillingContent/UsageContent/UsageContent.tsx
@@ -14,14 +14,16 @@ import React from 'react'
import { parseNumberWithCommas } from 'utils'
import { getChatsLimit, getStorageLimit } from 'utils/pricing'
import { storageToReadable } from './helpers'
-import { useUsage } from '../../../hooks/useUsage'
+import { trpc } from '@/lib/trpc'
type Props = {
workspace: Workspace
}
export const UsageContent = ({ workspace }: Props) => {
- const { data, isLoading } = useUsage(workspace.id)
+ const { data, isLoading } = trpc.billing.getUsage.useQuery({
+ workspaceId: workspace.id,
+ })
const totalChatsUsed = data?.totalChatsUsed ?? 0
const totalStorageUsed = data?.totalStorageUsed ?? 0
diff --git a/apps/builder/src/features/billing/components/BillingContent/queries/redirectToBillingPortal.ts b/apps/builder/src/features/billing/components/BillingContent/queries/redirectToBillingPortal.ts
deleted file mode 100644
index f40dd5e54..000000000
--- a/apps/builder/src/features/billing/components/BillingContent/queries/redirectToBillingPortal.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { sendRequest } from 'utils'
-
-export const redirectToBillingPortal = ({
- workspaceId,
-}: {
- workspaceId: string
-}) => sendRequest(`/api/stripe/billing-portal?workspaceId=${workspaceId}`)
diff --git a/apps/builder/src/features/billing/components/BillingContent/queries/useInvoicesQuery.ts b/apps/builder/src/features/billing/components/BillingContent/queries/useInvoicesQuery.ts
deleted file mode 100644
index 2949ae14e..000000000
--- a/apps/builder/src/features/billing/components/BillingContent/queries/useInvoicesQuery.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { fetcher } from '@/utils/helpers'
-import useSWR from 'swr'
-import { env } from 'utils'
-
-type Invoice = {
- id: string
- url: string | null
- date: number
- currency: string
- amount: number
-}
-export const useInvoicesQuery = (stripeId?: string | null) => {
- const { data, error } = useSWR<{ invoices: Invoice[] }, Error>(
- stripeId ? `/api/stripe/invoices?stripeId=${stripeId}` : null,
- fetcher,
- {
- dedupingInterval: env('E2E_TEST') === 'true' ? 0 : undefined,
- }
- )
- return {
- invoices: data?.invoices ?? [],
- isLoading: !error && !data,
- }
-}
diff --git a/apps/builder/src/features/billing/components/ChangePlanForm/ChangePlanForm.tsx b/apps/builder/src/features/billing/components/ChangePlanForm/ChangePlanForm.tsx
index 85e0efb15..cd3bb933a 100644
--- a/apps/builder/src/features/billing/components/ChangePlanForm/ChangePlanForm.tsx
+++ b/apps/builder/src/features/billing/components/ChangePlanForm/ChangePlanForm.tsx
@@ -1,22 +1,54 @@
import { Stack, HStack, Text } from '@chakra-ui/react'
import { useUser } from '@/features/account'
-import { useWorkspace } from '@/features/workspace'
import { Plan } from 'db'
import { ProPlanContent } from './ProPlanContent'
-import { upgradePlanQuery } from '../../queries/upgradePlanQuery'
-import { useCurrentSubscriptionInfo } from '../../hooks/useCurrentSubscriptionInfo'
import { StarterPlanContent } from './StarterPlanContent'
import { TextLink } from '@/components/TextLink'
import { useToast } from '@/hooks/useToast'
+import { trpc } from '@/lib/trpc'
+import { guessIfUserIsEuropean } from 'utils/pricing'
+import { useRouter } from 'next/router'
+import { Workspace } from 'models'
-export const ChangePlanForm = () => {
+type Props = {
+ workspace: Pick
+ onUpgradeSuccess: () => void
+}
+
+export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
+ const router = useRouter()
const { user } = useUser()
- const { workspace, refreshWorkspace } = useWorkspace()
const { showToast } = useToast()
- const { data, mutate: refreshCurrentSubscriptionInfo } =
- useCurrentSubscriptionInfo({
- stripeId: workspace?.stripeId,
- plan: workspace?.plan,
+ const { data } = trpc.billing.getSubscription.useQuery({
+ workspaceId: workspace.id,
+ })
+
+ const { mutate: createCheckoutSession, isLoading: isCreatingCheckout } =
+ trpc.billing.createCheckoutSession.useMutation({
+ onError: (error) => {
+ showToast({
+ description: error.message,
+ })
+ },
+ onSuccess: ({ checkoutUrl }) => {
+ router.push(checkoutUrl)
+ },
+ })
+
+ const { mutate: updateSubscription, isLoading: isUpdatingSubscription } =
+ trpc.billing.updateSubscription.useMutation({
+ onError: (error) => {
+ showToast({
+ description: error.message,
+ })
+ },
+ onSuccess: ({ workspace: { plan } }) => {
+ onUpgradeSuccess()
+ showToast({
+ status: 'success',
+ description: `Workspace ${plan} plan successfully updated 🎉`,
+ })
+ },
})
const handlePayClick = async ({
@@ -30,33 +62,29 @@ export const ChangePlanForm = () => {
}) => {
if (
!user ||
- !workspace ||
selectedChatsLimitIndex === undefined ||
selectedStorageLimitIndex === undefined
)
return
- const response = await upgradePlanQuery({
- stripeId: workspace.stripeId ?? undefined,
- user,
+
+ const newSubscription = {
plan,
workspaceId: workspace.id,
additionalChats: selectedChatsLimitIndex,
additionalStorage: selectedStorageLimitIndex,
- currency: data?.currency,
- })
- if (typeof response === 'object' && response?.error) {
- showToast({ description: response.error.message })
- return
+ currency:
+ data?.subscription.currency ??
+ (guessIfUserIsEuropean() ? 'eur' : 'usd'),
+ } as const
+ if (workspace.stripeId) {
+ updateSubscription(newSubscription)
+ } else {
+ createCheckoutSession({
+ ...newSubscription,
+ returnUrl: window.location.href,
+ prefilledEmail: user.email ?? undefined,
+ })
}
- refreshCurrentSubscriptionInfo({
- additionalChatsIndex: selectedChatsLimitIndex,
- additionalStorageIndex: selectedStorageLimitIndex,
- })
- refreshWorkspace()
- showToast({
- status: 'success',
- description: `Workspace ${plan} plan successfully updated 🎉`,
- })
}
return (
@@ -64,26 +92,36 @@ export const ChangePlanForm = () => {
handlePayClick({ ...props, plan: Plan.STARTER })
}
- currency={data?.currency}
+ isLoading={isCreatingCheckout || isUpdatingSubscription}
+ currency={data?.subscription.currency}
/>
handlePayClick({ ...props, plan: Plan.PRO })}
- currency={data?.currency}
+ isLoading={isCreatingCheckout || isUpdatingSubscription}
+ currency={data?.subscription.currency}
/>
diff --git a/apps/builder/src/features/billing/components/ChangePlanForm/ProPlanContent.tsx b/apps/builder/src/features/billing/components/ChangePlanForm/ProPlanContent.tsx
index f409ae43e..e27c5b87e 100644
--- a/apps/builder/src/features/billing/components/ChangePlanForm/ProPlanContent.tsx
+++ b/apps/builder/src/features/billing/components/ChangePlanForm/ProPlanContent.tsx
@@ -34,16 +34,18 @@ type ProPlanContentProps = {
initialChatsLimitIndex?: number
initialStorageLimitIndex?: number
currency?: 'usd' | 'eur'
+ isLoading: boolean
onPayClick: (props: {
selectedChatsLimitIndex: number
selectedStorageLimitIndex: number
- }) => Promise
+ }) => void
}
export const ProPlanContent = ({
initialChatsLimitIndex,
initialStorageLimitIndex,
currency,
+ isLoading,
onPayClick,
}: ProPlanContentProps) => {
const { workspace } = useWorkspace()
@@ -51,7 +53,6 @@ export const ProPlanContent = ({
useState()
const [selectedStorageLimitIndex, setSelectedStorageLimitIndex] =
useState()
- const [isPaying, setIsPaying] = useState(false)
useEffect(() => {
if (
@@ -110,12 +111,10 @@ export const ProPlanContent = ({
selectedStorageLimitIndex === undefined
)
return
- setIsPaying(true)
- await onPayClick({
+ onPayClick({
selectedChatsLimitIndex,
selectedStorageLimitIndex,
})
- setIsPaying(false)
}
return (
@@ -335,7 +334,7 @@ export const ProPlanContent = ({
colorScheme="blue"
variant="outline"
onClick={handlePayClick}
- isLoading={isPaying}
+ isLoading={isLoading}
isDisabled={isCurrentPlan}
>
{getButtonLabel()}
diff --git a/apps/builder/src/features/billing/components/ChangePlanForm/StarterPlanContent.tsx b/apps/builder/src/features/billing/components/ChangePlanForm/StarterPlanContent.tsx
index c2ad70306..cc9e33147 100644
--- a/apps/builder/src/features/billing/components/ChangePlanForm/StarterPlanContent.tsx
+++ b/apps/builder/src/features/billing/components/ChangePlanForm/StarterPlanContent.tsx
@@ -30,15 +30,17 @@ type StarterPlanContentProps = {
initialChatsLimitIndex?: number
initialStorageLimitIndex?: number
currency?: 'eur' | 'usd'
+ isLoading?: boolean
onPayClick: (props: {
selectedChatsLimitIndex: number
selectedStorageLimitIndex: number
- }) => Promise
+ }) => void
}
export const StarterPlanContent = ({
initialChatsLimitIndex,
initialStorageLimitIndex,
+ isLoading,
currency,
onPayClick,
}: StarterPlanContentProps) => {
@@ -47,7 +49,6 @@ export const StarterPlanContent = ({
useState()
const [selectedStorageLimitIndex, setSelectedStorageLimitIndex] =
useState()
- const [isPaying, setIsPaying] = useState(false)
useEffect(() => {
if (
@@ -107,12 +108,10 @@ export const StarterPlanContent = ({
selectedStorageLimitIndex === undefined
)
return
- setIsPaying(true)
- await onPayClick({
+ onPayClick({
selectedChatsLimitIndex,
selectedStorageLimitIndex,
})
- setIsPaying(false)
}
return (
@@ -278,7 +277,7 @@ export const StarterPlanContent = ({
colorScheme="orange"
variant="outline"
onClick={handlePayClick}
- isLoading={isPaying}
+ isLoading={isLoading}
isDisabled={isCurrentPlan}
>
{getButtonLabel()}
diff --git a/apps/builder/src/features/billing/components/ChangePlanModal.tsx b/apps/builder/src/features/billing/components/ChangePlanModal.tsx
index 1ed23392a..1b09d0d89 100644
--- a/apps/builder/src/features/billing/components/ChangePlanModal.tsx
+++ b/apps/builder/src/features/billing/components/ChangePlanModal.tsx
@@ -1,4 +1,5 @@
import { AlertInfo } from '@/components/AlertInfo'
+import { useWorkspace } from '@/features/workspace'
import {
Modal,
ModalBody,
@@ -30,6 +31,7 @@ export const ChangePlanModal = ({
isOpen,
type,
}: ChangePlanModalProps) => {
+ const { workspace, refreshWorkspace } = useWorkspace()
return (
@@ -40,7 +42,12 @@ export const ChangePlanModal = ({
You need to upgrade your plan in order to {type}
)}
-
+ {workspace && (
+
+ )}
diff --git a/apps/builder/src/features/billing/hooks/useCurrentSubscriptionInfo.ts b/apps/builder/src/features/billing/hooks/useCurrentSubscriptionInfo.ts
deleted file mode 100644
index 0d30f8a43..000000000
--- a/apps/builder/src/features/billing/hooks/useCurrentSubscriptionInfo.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { fetcher } from '@/utils/helpers'
-import { Plan } from 'db'
-import useSWR from 'swr'
-
-export const useCurrentSubscriptionInfo = ({
- stripeId,
- plan,
-}: {
- stripeId?: string | null
- plan?: Plan
-}) => {
- const { data, mutate } = useSWR<
- {
- additionalChatsIndex: number
- additionalStorageIndex: number
- currency?: 'eur' | 'usd'
- },
- Error
- >(
- stripeId && (plan === Plan.STARTER || plan === Plan.PRO)
- ? `/api/stripe/subscription?stripeId=${stripeId}`
- : null,
- fetcher
- )
- return {
- data: !stripeId
- ? { additionalChatsIndex: 0, additionalStorageIndex: 0 }
- : data,
- mutate,
- }
-}
diff --git a/apps/builder/src/features/billing/hooks/useUsage.ts b/apps/builder/src/features/billing/hooks/useUsage.ts
deleted file mode 100644
index fdda942a2..000000000
--- a/apps/builder/src/features/billing/hooks/useUsage.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { fetcher } from '@/utils/helpers'
-import useSWR from 'swr'
-import { env } from 'utils'
-
-export const useUsage = (workspaceId?: string) => {
- const { data, error } = useSWR<
- { totalChatsUsed: number; totalStorageUsed: number },
- Error
- >(workspaceId ? `/api/workspaces/${workspaceId}/usage` : null, fetcher, {
- dedupingInterval: env('E2E_TEST') === 'true' ? 0 : undefined,
- })
- return {
- data,
- isLoading: !error && !data,
- }
-}
diff --git a/apps/builder/src/features/billing/index.ts b/apps/builder/src/features/billing/index.ts
index 48f158845..2665d9091 100644
--- a/apps/builder/src/features/billing/index.ts
+++ b/apps/builder/src/features/billing/index.ts
@@ -1,8 +1,6 @@
export { ChangePlanModal, LimitReached } from './components/ChangePlanModal'
export { planToReadable, isFreePlan, isProPlan } from './utils'
-export { upgradePlanQuery } from './queries/upgradePlanQuery'
export { BillingContent } from './components/BillingContent'
export { LockTag } from './components/LockTag'
-export { useUsage } from './hooks/useUsage'
export { UpgradeButton } from './components/UpgradeButton'
export { PlanTag } from './components/PlanTag'
diff --git a/apps/builder/src/features/billing/queries/upgradePlanQuery.tsx b/apps/builder/src/features/billing/queries/upgradePlanQuery.tsx
deleted file mode 100644
index e7e2f2d5a..000000000
--- a/apps/builder/src/features/billing/queries/upgradePlanQuery.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-import { loadStripe } from '@stripe/stripe-js/pure'
-import { Plan, User } from 'db'
-import { env, isDefined, isEmpty, sendRequest } from 'utils'
-import { guessIfUserIsEuropean } from 'utils/pricing'
-
-type UpgradeProps = {
- user: User
- stripeId?: string
- plan: Plan
- workspaceId: string
- additionalChats: number
- additionalStorage: number
- currency?: 'eur' | 'usd'
-}
-
-export const upgradePlanQuery = async ({
- stripeId,
- ...props
-}: UpgradeProps): Promise<{ newPlan?: Plan; error?: Error } | void> =>
- isDefined(stripeId)
- ? updatePlan({ ...props, stripeId })
- : redirectToCheckout(props)
-
-const updatePlan = async ({
- stripeId,
- plan,
- workspaceId,
- additionalChats,
- additionalStorage,
- currency,
-}: Omit): Promise<{ newPlan?: Plan; error?: Error }> => {
- const { data, error } = await sendRequest<{ message: string }>({
- method: 'PUT',
- url: '/api/stripe/subscription',
- body: {
- workspaceId,
- plan,
- stripeId,
- additionalChats,
- additionalStorage,
- currency: currency ?? (guessIfUserIsEuropean() ? 'eur' : 'usd'),
- },
- })
- if (error || !data) return { error }
- return { newPlan: plan }
-}
-
-const redirectToCheckout = async ({
- user,
- plan,
- workspaceId,
- additionalChats,
- additionalStorage,
-}: Omit) => {
- if (isEmpty(env('STRIPE_PUBLIC_KEY')))
- throw new Error('NEXT_PUBLIC_STRIPE_PUBLIC_KEY is missing in env')
- const { data, error } = await sendRequest<{ sessionId: string }>({
- method: 'POST',
- url: '/api/stripe/subscription',
- body: {
- email: user.email,
- currency: guessIfUserIsEuropean() ? 'eur' : 'usd',
- plan,
- workspaceId,
- href: location.origin + location.pathname,
- additionalChats,
- additionalStorage,
- },
- })
- if (error || !data) return
- const stripe = await loadStripe(env('STRIPE_PUBLIC_KEY') as string)
- await stripe?.redirectToCheckout({
- sessionId: data?.sessionId,
- })
-}
diff --git a/apps/builder/src/features/dashboard/components/DashboardPage.tsx b/apps/builder/src/features/dashboard/components/DashboardPage.tsx
index 91f49200d..e180e1a88 100644
--- a/apps/builder/src/features/dashboard/components/DashboardPage.tsx
+++ b/apps/builder/src/features/dashboard/components/DashboardPage.tsx
@@ -1,19 +1,26 @@
import { Seo } from '@/components/Seo'
import { useUser } from '@/features/account'
-import { upgradePlanQuery } from '@/features/billing'
import { TypebotDndProvider, FolderContent } from '@/features/folders'
import { useWorkspace } from '@/features/workspace'
+import { trpc } from '@/lib/trpc'
import { Stack, VStack, Spinner, Text } from '@chakra-ui/react'
import { Plan } from 'db'
import { useRouter } from 'next/router'
import { useState, useEffect } from 'react'
+import { guessIfUserIsEuropean } from 'utils/pricing'
import { DashboardHeader } from './DashboardHeader'
export const DashboardPage = () => {
const [isLoading, setIsLoading] = useState(false)
- const { query } = useRouter()
+ const { query, push } = useRouter()
const { user } = useUser()
const { workspace } = useWorkspace()
+ const { mutate: createCheckoutSession } =
+ trpc.billing.createCheckoutSession.useMutation({
+ onSuccess: (data) => {
+ push(data.checkoutUrl)
+ },
+ })
useEffect(() => {
const { subscribePlan, chats, storage } = query as {
@@ -23,15 +30,17 @@ export const DashboardPage = () => {
}
if (workspace && subscribePlan && user && workspace.plan === 'FREE') {
setIsLoading(true)
- upgradePlanQuery({
- user,
- plan: subscribePlan,
+ createCheckoutSession({
+ plan: subscribePlan as 'PRO' | 'STARTER',
workspaceId: workspace.id,
additionalChats: chats ? parseInt(chats) : 0,
additionalStorage: storage ? parseInt(storage) : 0,
+ returnUrl: window.location.href,
+ currency: guessIfUserIsEuropean() ? 'eur' : 'usd',
+ prefilledEmail: user.email ?? undefined,
})
}
- }, [query, user, workspace])
+ }, [createCheckoutSession, query, user, workspace])
return (
diff --git a/apps/builder/src/features/results/components/ResultsPage.tsx b/apps/builder/src/features/results/components/ResultsPage.tsx
index ddb326d87..bbf601be2 100644
--- a/apps/builder/src/features/results/components/ResultsPage.tsx
+++ b/apps/builder/src/features/results/components/ResultsPage.tsx
@@ -1,7 +1,5 @@
import { Seo } from '@/components/Seo'
-import { UnlockPlanAlertInfo } from '@/components/UnlockPlanAlertInfo'
import { AnalyticsGraphContainer } from '@/features/analytics'
-import { useUsage } from '@/features/billing'
import { useTypebot, TypebotHeader } from '@/features/editor'
import { useWorkspace } from '@/features/workspace'
import { useToast } from '@/hooks/useToast'
@@ -16,13 +14,10 @@ import {
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useMemo } from 'react'
-import { getChatsLimit, getStorageLimit } from 'utils/pricing'
import { useStats } from '../hooks/useStats'
import { ResultsProvider } from '../ResultsProvider'
import { ResultsTableContainer } from './ResultsTableContainer'
-
-const ALERT_CHATS_PERCENT_THRESHOLD = 80
-const ALERT_STORAGE_PERCENT_THRESHOLD = 80
+import { UsageAlertBanners } from './UsageAlertBanners'
export const ResultsPage = () => {
const router = useRouter()
@@ -38,46 +33,6 @@ export const ResultsPage = () => {
typebotId: publishedTypebot?.typebotId,
onError: (err) => showToast({ title: err.name, description: err.message }),
})
- const { data: usageData } = useUsage(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,
- ])
-
- const storageLimitPercentage = useMemo(() => {
- if (!usageData?.totalStorageUsed || !workspace?.plan) return 0
- return Math.round(
- (usageData.totalStorageUsed /
- 1024 /
- 1024 /
- 1024 /
- getStorageLimit({
- additionalStorageIndex: workspace.additionalStorageIndex,
- plan: workspace.plan,
- customStorageLimit: workspace.customStorageLimit,
- })) *
- 100
- )
- }, [
- usageData?.totalStorageUsed,
- workspace?.additionalStorageIndex,
- workspace?.customStorageLimit,
- workspace?.plan,
- ])
const handleDeletedResults = (total: number) => {
if (!stats) return
@@ -100,38 +55,7 @@ export const ResultsPage = () => {
}
/>
- {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.
- >
- }
- buttonLabel="Upgrade"
- />
-
- )}
- {storageLimitPercentage > ALERT_STORAGE_PERCENT_THRESHOLD && (
-
-
- Your workspace collected{' '}
- {storageLimitPercentage}% of your total storage
- allowed. Upgrade your plan or delete some existing results to
- continue collecting files from your user beyond this limit.
- >
- }
- buttonLabel="Upgrade"
- />
-
- )}
+ {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,
+ ])
+
+ const storageLimitPercentage = useMemo(() => {
+ if (!usageData?.totalStorageUsed || !workspace?.plan) return 0
+ return Math.round(
+ (usageData.totalStorageUsed /
+ 1024 /
+ 1024 /
+ 1024 /
+ getStorageLimit({
+ additionalStorageIndex: workspace.additionalStorageIndex,
+ plan: workspace.plan,
+ customStorageLimit: workspace.customStorageLimit,
+ })) *
+ 100
+ )
+ }, [
+ usageData?.totalStorageUsed,
+ workspace?.additionalStorageIndex,
+ workspace?.customStorageLimit,
+ 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.
+ >
+ }
+ buttonLabel="Upgrade"
+ />
+
+ )}
+ {storageLimitPercentage > ALERT_STORAGE_PERCENT_THRESHOLD && (
+
+
+ Your workspace collected{' '}
+ {storageLimitPercentage}% of your total storage
+ allowed. Upgrade your plan or delete some existing results to
+ continue collecting files from your user beyond this limit.
+ >
+ }
+ buttonLabel="Upgrade"
+ />
+
+ )}
+ >
+ )
+}
diff --git a/apps/builder/src/features/workspace/WorkspaceProvider.tsx b/apps/builder/src/features/workspace/WorkspaceProvider.tsx
index 5f4b1b1cb..ed20f26df 100644
--- a/apps/builder/src/features/workspace/WorkspaceProvider.tsx
+++ b/apps/builder/src/features/workspace/WorkspaceProvider.tsx
@@ -158,6 +158,7 @@ export const WorkspaceProvider = ({
const refreshWorkspace = () => {
trpcContext.workspace.getWorkspace.invalidate()
+ trpcContext.billing.getSubscription.invalidate()
}
return (
diff --git a/apps/builder/src/pages/api/stripe/billing-portal.ts b/apps/builder/src/pages/api/stripe/billing-portal.ts
index 315f6d6bc..08bfb9dca 100644
--- a/apps/builder/src/pages/api/stripe/billing-portal.ts
+++ b/apps/builder/src/pages/api/stripe/billing-portal.ts
@@ -10,6 +10,7 @@ import { getAuthenticatedUser } from '@/features/auth/api'
import prisma from '@/lib/prisma'
import { WorkspaceRole } from 'db'
+// TO-DO: Delete
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res)
diff --git a/apps/builder/src/pages/api/stripe/invoices.ts b/apps/builder/src/pages/api/stripe/invoices.ts
index 56c90bce9..a63b9494f 100644
--- a/apps/builder/src/pages/api/stripe/invoices.ts
+++ b/apps/builder/src/pages/api/stripe/invoices.ts
@@ -10,6 +10,7 @@ import { getAuthenticatedUser } from '@/features/auth/api'
import prisma from '@/lib/prisma'
import { WorkspaceRole } from 'db'
+// TODO: Delete
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res)
diff --git a/apps/builder/src/pages/api/stripe/subscription.ts b/apps/builder/src/pages/api/stripe/subscription.ts
index af28b0f3e..632db9bc8 100644
--- a/apps/builder/src/pages/api/stripe/subscription.ts
+++ b/apps/builder/src/pages/api/stripe/subscription.ts
@@ -11,6 +11,7 @@ import { getAuthenticatedUser } from '@/features/auth/api'
import prisma from '@/lib/prisma'
import { Plan, WorkspaceRole } from 'db'
+// TODO: Delete
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res)
diff --git a/apps/builder/src/pages/api/trpc/[trpc].ts b/apps/builder/src/pages/api/trpc/[trpc].ts
index b8415cfe4..5332602ea 100644
--- a/apps/builder/src/pages/api/trpc/[trpc].ts
+++ b/apps/builder/src/pages/api/trpc/[trpc].ts
@@ -11,5 +11,9 @@ export default createNextApiHandler({
captureException(error)
console.error('Something went wrong', error)
}
+ return error
+ },
+ batching: {
+ enabled: true,
},
})
diff --git a/apps/builder/src/pages/api/workspaces/[workspaceId]/usage.ts b/apps/builder/src/pages/api/workspaces/[workspaceId]/usage.ts
index bb9b5e0c1..259c0220c 100644
--- a/apps/builder/src/pages/api/workspaces/[workspaceId]/usage.ts
+++ b/apps/builder/src/pages/api/workspaces/[workspaceId]/usage.ts
@@ -3,6 +3,7 @@ import { NextApiRequest, NextApiResponse } from 'next'
import { getAuthenticatedUser } from '@/features/auth/api'
import { methodNotAllowed, notAuthenticated } from 'utils/api'
+// TODO: Delete
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res)
diff --git a/apps/builder/src/utils/server/routers/v1/trpcRouter.ts b/apps/builder/src/utils/server/routers/v1/trpcRouter.ts
index f947686ad..dc78b5a5a 100644
--- a/apps/builder/src/utils/server/routers/v1/trpcRouter.ts
+++ b/apps/builder/src/utils/server/routers/v1/trpcRouter.ts
@@ -1,3 +1,4 @@
+import { billingRouter } from '@/features/billing/api/router'
import { webhookRouter } from '@/features/blocks/integrations/webhook/api'
import { resultsRouter } from '@/features/results/api'
import { typebotRouter } from '@/features/typebot/api'
@@ -9,6 +10,7 @@ export const trpcRouter = router({
typebot: typebotRouter,
webhook: webhookRouter,
results: resultsRouter,
+ billing: billingRouter,
})
export type AppRouter = typeof trpcRouter
diff --git a/apps/builder/src/utils/server/trpc.ts b/apps/builder/src/utils/server/trpc.ts
index 2b661ffe3..5314fbe07 100644
--- a/apps/builder/src/utils/server/trpc.ts
+++ b/apps/builder/src/utils/server/trpc.ts
@@ -8,7 +8,7 @@ const t = initTRPC.context().meta().create({
})
const isAuthed = t.middleware(({ next, ctx }) => {
- if (!ctx.user) {
+ if (!ctx.user?.id) {
throw new TRPCError({
code: 'UNAUTHORIZED',
})
diff --git a/apps/docs/openapi/builder/_spec_.json b/apps/docs/openapi/builder/_spec_.json
index 34c54de03..1adf1261f 100644
--- a/apps/docs/openapi/builder/_spec_.json
+++ b/apps/docs/openapi/builder/_spec_.json
@@ -1400,6 +1400,563 @@
}
}
}
+ },
+ "/billing/subscription/portal": {
+ "get": {
+ "operationId": "query.billing.getBillingPortalUrl",
+ "summary": "Get Stripe billing portal URL",
+ "tags": [
+ "Billing"
+ ],
+ "security": [
+ {
+ "Authorization": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "workspaceId",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "billingPortalUrl": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "billingPortalUrl"
+ ],
+ "additionalProperties": false
+ }
+ }
+ }
+ },
+ "default": {
+ "$ref": "#/components/responses/error"
+ }
+ }
+ }
+ },
+ "/billing/invoices": {
+ "get": {
+ "operationId": "query.billing.listInvoices",
+ "summary": "List invoices",
+ "tags": [
+ "Billing"
+ ],
+ "security": [
+ {
+ "Authorization": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "workspaceId",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "invoices": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "url": {
+ "type": "string"
+ },
+ "amount": {
+ "type": "number"
+ },
+ "currency": {
+ "type": "string"
+ },
+ "date": {
+ "type": "number",
+ "nullable": true
+ }
+ },
+ "required": [
+ "id",
+ "url",
+ "amount",
+ "currency",
+ "date"
+ ],
+ "additionalProperties": false
+ }
+ }
+ },
+ "required": [
+ "invoices"
+ ],
+ "additionalProperties": false
+ }
+ }
+ }
+ },
+ "default": {
+ "$ref": "#/components/responses/error"
+ }
+ }
+ }
+ },
+ "/billing/subscription": {
+ "delete": {
+ "operationId": "mutation.billing.cancelSubscription",
+ "summary": "Cancel current subscription",
+ "tags": [
+ "Billing"
+ ],
+ "security": [
+ {
+ "Authorization": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "workspaceId",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "message": {
+ "type": "string",
+ "enum": [
+ "success"
+ ]
+ }
+ },
+ "required": [
+ "message"
+ ],
+ "additionalProperties": false
+ }
+ }
+ }
+ },
+ "default": {
+ "$ref": "#/components/responses/error"
+ }
+ }
+ },
+ "patch": {
+ "operationId": "mutation.billing.updateSubscription",
+ "summary": "Update subscription",
+ "tags": [
+ "Billing"
+ ],
+ "security": [
+ {
+ "Authorization": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "workspaceId": {
+ "type": "string"
+ },
+ "plan": {
+ "type": "string",
+ "enum": [
+ "STARTER",
+ "PRO"
+ ]
+ },
+ "additionalChats": {
+ "type": "number"
+ },
+ "additionalStorage": {
+ "type": "number"
+ },
+ "currency": {
+ "type": "string",
+ "enum": [
+ "usd",
+ "eur"
+ ]
+ }
+ },
+ "required": [
+ "workspaceId",
+ "plan",
+ "additionalChats",
+ "additionalStorage",
+ "currency"
+ ],
+ "additionalProperties": false
+ }
+ }
+ }
+ },
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "Successful response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "workspace": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "name": {
+ "type": "string"
+ },
+ "icon": {
+ "type": "string",
+ "nullable": true
+ },
+ "plan": {
+ "type": "string",
+ "enum": [
+ "FREE",
+ "STARTER",
+ "PRO",
+ "LIFETIME",
+ "OFFERED",
+ "CUSTOM",
+ "UNLIMITED"
+ ]
+ },
+ "stripeId": {
+ "type": "string",
+ "nullable": true
+ },
+ "additionalChatsIndex": {
+ "type": "number"
+ },
+ "additionalStorageIndex": {
+ "type": "number"
+ },
+ "chatsLimitFirstEmailSentAt": {
+ "type": "string",
+ "format": "date-time",
+ "nullable": true
+ },
+ "chatsLimitSecondEmailSentAt": {
+ "type": "string",
+ "format": "date-time",
+ "nullable": true
+ },
+ "storageLimitFirstEmailSentAt": {
+ "type": "string",
+ "format": "date-time",
+ "nullable": true
+ },
+ "storageLimitSecondEmailSentAt": {
+ "type": "string",
+ "format": "date-time",
+ "nullable": true
+ },
+ "customChatsLimit": {
+ "type": "number",
+ "nullable": true
+ },
+ "customStorageLimit": {
+ "type": "number",
+ "nullable": true
+ },
+ "customSeatsLimit": {
+ "type": "number",
+ "nullable": true
+ }
+ },
+ "required": [
+ "id",
+ "createdAt",
+ "updatedAt",
+ "name",
+ "icon",
+ "plan",
+ "stripeId",
+ "additionalChatsIndex",
+ "additionalStorageIndex",
+ "chatsLimitFirstEmailSentAt",
+ "chatsLimitSecondEmailSentAt",
+ "storageLimitFirstEmailSentAt",
+ "storageLimitSecondEmailSentAt",
+ "customChatsLimit",
+ "customStorageLimit",
+ "customSeatsLimit"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "workspace"
+ ],
+ "additionalProperties": false
+ }
+ }
+ }
+ },
+ "default": {
+ "$ref": "#/components/responses/error"
+ }
+ }
+ },
+ "get": {
+ "operationId": "query.billing.getSubscription",
+ "summary": "List invoices",
+ "tags": [
+ "Billing"
+ ],
+ "security": [
+ {
+ "Authorization": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "workspaceId",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "subscription": {
+ "type": "object",
+ "properties": {
+ "additionalChatsIndex": {
+ "type": "number"
+ },
+ "additionalStorageIndex": {
+ "type": "number"
+ },
+ "currency": {
+ "type": "string",
+ "enum": [
+ "eur",
+ "usd"
+ ]
+ }
+ },
+ "required": [
+ "additionalChatsIndex",
+ "additionalStorageIndex",
+ "currency"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "subscription"
+ ],
+ "additionalProperties": false
+ }
+ }
+ }
+ },
+ "default": {
+ "$ref": "#/components/responses/error"
+ }
+ }
+ }
+ },
+ "/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": {
+ "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"
+ }
+ },
+ "required": [
+ "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",
+ "summary": "Get current plan usage",
+ "tags": [
+ "Billing"
+ ],
+ "security": [
+ {
+ "Authorization": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "workspaceId",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "totalChatsUsed": {
+ "type": "number"
+ },
+ "totalStorageUsed": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "totalChatsUsed",
+ "totalStorageUsed"
+ ],
+ "additionalProperties": false
+ }
+ }
+ }
+ },
+ "default": {
+ "$ref": "#/components/responses/error"
+ }
+ }
+ }
}
},
"components": {
diff --git a/packages/models/features/billing/invoice.ts b/packages/models/features/billing/invoice.ts
new file mode 100644
index 000000000..727290f0a
--- /dev/null
+++ b/packages/models/features/billing/invoice.ts
@@ -0,0 +1,11 @@
+import { z } from 'zod'
+
+export const invoiceSchema = z.object({
+ id: z.string(),
+ url: z.string(),
+ amount: z.number(),
+ currency: z.string(),
+ date: z.number().nullable(),
+})
+
+export type Invoice = z.infer
diff --git a/packages/models/features/billing/subscription.ts b/packages/models/features/billing/subscription.ts
new file mode 100644
index 000000000..2fa571206
--- /dev/null
+++ b/packages/models/features/billing/subscription.ts
@@ -0,0 +1,9 @@
+import { z } from 'zod'
+
+export const subscriptionSchema = z.object({
+ additionalChatsIndex: z.number(),
+ additionalStorageIndex: z.number(),
+ currency: z.enum(['eur', 'usd']),
+})
+
+export type Subscription = z.infer
|