2
0

(billing) Implement custom plan

This commit is contained in:
Baptiste Arnaud
2022-10-27 11:32:21 +02:00
committed by Baptiste Arnaud
parent 3f7dc79918
commit 385853ca3c
23 changed files with 395 additions and 68 deletions

View File

@ -1,4 +1,6 @@
import { Stack } from '@chakra-ui/react' import { HStack, Stack, Text } from '@chakra-ui/react'
import { StripeClimateLogo } from 'assets/logos/StripeClimateLogo'
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
import { ChangePlanForm } from 'components/shared/ChangePlanForm' import { ChangePlanForm } from 'components/shared/ChangePlanForm'
import { useWorkspace } from 'contexts/WorkspaceContext' import { useWorkspace } from 'contexts/WorkspaceContext'
import { Plan } from 'db' import { Plan } from 'db'
@ -26,7 +28,22 @@ export const BillingContent = () => {
}) })
} }
/> />
{workspace.plan !== Plan.LIFETIME && <HStack maxW="500px">
<StripeClimateLogo />
<Text fontSize="xs" color="gray.500">
Typebot is contributing 1% of your subscription to remove CO from
the atmosphere.{' '}
<NextChakraLink
href="https://climate.stripe.com/5VCRAq"
isExternal
textDecor="underline"
>
More info.
</NextChakraLink>
</Text>
</HStack>
{workspace.plan !== Plan.CUSTOM &&
workspace.plan !== Plan.LIFETIME &&
workspace.plan !== Plan.OFFERED && <ChangePlanForm />} workspace.plan !== Plan.OFFERED && <ChangePlanForm />}
</Stack> </Stack>

View File

@ -49,7 +49,7 @@ export const AddMemberForm = ({
} }
return ( return (
<HStack as="form" onSubmit={handleInvitationSubmit} pb="4"> <HStack as="form" onSubmit={handleInvitationSubmit}>
<Input <Input
placeholder="colleague@company.com" placeholder="colleague@company.com"
name="inviteEmail" name="inviteEmail"

View File

@ -1,4 +1,10 @@
import { HStack, SkeletonCircle, SkeletonText, Stack } from '@chakra-ui/react' import {
Heading,
HStack,
SkeletonCircle,
SkeletonText,
Stack,
} from '@chakra-ui/react'
import { UnlockPlanInfo } from 'components/shared/Info' import { UnlockPlanInfo } from 'components/shared/Info'
import { useUser } from 'contexts/UserContext' import { useUser } from 'contexts/UserContext'
import { useWorkspace } from 'contexts/WorkspaceContext' import { useWorkspace } from 'contexts/WorkspaceContext'
@ -12,6 +18,7 @@ import {
updateMember, updateMember,
useMembers, useMembers,
} from 'services/workspace' } from 'services/workspace'
import { getSeatsLimit } from 'utils'
import { AddMemberForm } from './AddMemberForm' import { AddMemberForm } from './AddMemberForm'
import { checkCanInviteMember } from './helpers' import { checkCanInviteMember } from './helpers'
import { MemberItem } from './MemberItem' import { MemberItem } from './MemberItem'
@ -77,9 +84,12 @@ export const MembersList = () => {
}) })
} }
const currentMembersCount = members.length + invitations.length
const canInviteNewMember = checkCanInviteMember({ const canInviteNewMember = checkCanInviteMember({
plan: workspace?.plan, plan: workspace?.plan,
currentMembersCount: [...members, ...invitations].length, customSeatsLimit: workspace?.customSeatsLimit,
currentMembersCount,
}) })
return ( return (
@ -92,6 +102,11 @@ export const MembersList = () => {
`} `}
/> />
)} )}
{workspace && (
<Heading fontSize="2xl">
Members ({currentMembersCount}/{getSeatsLimit(workspace)})
</Heading>
)}
{workspace?.id && canEdit && ( {workspace?.id && canEdit && (
<AddMemberForm <AddMemberForm
workspaceId={workspace.id} workspaceId={workspace.id}

View File

@ -1,14 +1,19 @@
import { Plan } from 'db' import { Plan } from 'db'
import { seatsLimit } from 'utils' import { getSeatsLimit } from 'utils'
export function checkCanInviteMember({ export function checkCanInviteMember({
plan, plan,
customSeatsLimit,
currentMembersCount, currentMembersCount,
}: { }: {
plan?: Plan plan?: Plan
customSeatsLimit?: number | null
currentMembersCount?: number currentMembersCount?: number
}) { }) {
if (!plan || !currentMembersCount) return false if (!plan || !currentMembersCount) return false
return seatsLimit[plan].totalIncluded > currentMembersCount return (
getSeatsLimit({ plan, customSeatsLimit: customSeatsLimit ?? null }) >
currentMembersCount
)
} }

View File

@ -1,5 +1,4 @@
import { Stack, HStack, Text } from '@chakra-ui/react' import { Stack, HStack, Text } from '@chakra-ui/react'
import { StripeClimateLogo } from 'assets/logos/StripeClimateLogo'
import { NextChakraLink } from 'components/nextChakra/NextChakraLink' import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
import { useUser } from 'contexts/UserContext' import { useUser } from 'contexts/UserContext'
import { useWorkspace } from 'contexts/WorkspaceContext' import { useWorkspace } from 'contexts/WorkspaceContext'
@ -65,20 +64,6 @@ export const ChangePlanForm = () => {
return ( return (
<Stack spacing={6}> <Stack spacing={6}>
<HStack maxW="500px">
<StripeClimateLogo />
<Text fontSize="xs" color="gray.500">
Typebot is contributing 1% of your subscription to remove CO from the
atmosphere.{' '}
<NextChakraLink
href="https://climate.stripe.com/5VCRAq"
isExternal
textDecor="underline"
>
More info.
</NextChakraLink>
</Text>
</HStack>
<HStack alignItems="stretch" spacing="4" w="full"> <HStack alignItems="stretch" spacing="4" w="full">
<StarterPlanContent <StarterPlanContent
initialChatsLimitIndex={ initialChatsLimitIndex={

View File

@ -7,9 +7,13 @@ export const planColorSchemes: Record<Plan, ThemeTypings['colorSchemes']> = {
[Plan.OFFERED]: 'orange', [Plan.OFFERED]: 'orange',
[Plan.STARTER]: 'orange', [Plan.STARTER]: 'orange',
[Plan.FREE]: 'gray', [Plan.FREE]: 'gray',
[Plan.CUSTOM]: 'yellow',
} }
export const PlanTag = ({ plan, ...props }: { plan?: Plan } & TagProps) => { export const PlanTag = ({
plan,
...props
}: { plan: Plan } & TagProps): JSX.Element => {
switch (plan) { switch (plan) {
case Plan.LIFETIME: { case Plan.LIFETIME: {
return ( return (
@ -45,7 +49,7 @@ export const PlanTag = ({ plan, ...props }: { plan?: Plan } & TagProps) => {
</Tag> </Tag>
) )
} }
default: { case Plan.FREE: {
return ( return (
<Tag <Tag
colorScheme={planColorSchemes[Plan.FREE]} colorScheme={planColorSchemes[Plan.FREE]}
@ -56,5 +60,16 @@ export const PlanTag = ({ plan, ...props }: { plan?: Plan } & TagProps) => {
</Tag> </Tag>
) )
} }
case Plan.CUSTOM: {
return (
<Tag
colorScheme={planColorSchemes[Plan.CUSTOM]}
data-testid="free-plan-tag"
{...props}
>
Custom
</Tag>
)
}
} }
} }

View File

@ -41,6 +41,22 @@ type WorkspaceContextProps = {
children: ReactNode children: ReactNode
} }
const getNewWorkspaceName = (
userFullName: string | undefined,
existingWorkspaces: Workspace[]
) => {
const workspaceName = userFullName
? `${userFullName}'s workspace`
: 'My workspace'
let newName = workspaceName
let i = 1
while (existingWorkspaces.find((w) => w.name === newName)) {
newName = `${workspaceName} (${i})`
i++
}
return newName
}
export const WorkspaceContext = ({ children }: WorkspaceContextProps) => { export const WorkspaceContext = ({ children }: WorkspaceContextProps) => {
const { query } = useRouter() const { query } = useRouter()
const { user } = useUser() const { user } = useUser()
@ -97,10 +113,11 @@ export const WorkspaceContext = ({ children }: WorkspaceContextProps) => {
setCurrentWorkspace(newWorkspace) setCurrentWorkspace(newWorkspace)
} }
const createWorkspace = async (name?: string) => { const createWorkspace = async (userFullName?: string) => {
if (!workspaces) return if (!workspaces) return
const newWorkspaceName = getNewWorkspaceName(userFullName, workspaces)
const { data, error } = await createNewWorkspace({ const { data, error } = await createNewWorkspace({
name: name ? `${name}'s workspace` : 'My workspace', name: newWorkspaceName,
plan: Plan.FREE, plan: Plan.FREE,
}) })
if (error || !data) return if (error || !data) return

View File

@ -0,0 +1,61 @@
import { withSentry } from '@sentry/nextjs'
import { Plan } from 'db'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { getAuthenticatedUser } from 'services/api/utils'
import Stripe from 'stripe'
import { methodNotAllowed, notAuthenticated } from 'utils/api'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res)
if (req.method === 'GET') {
const session = await createCheckoutSession(user.id)
if (!session?.url) return res.redirect('/typebots')
return res.redirect(session.url)
}
return methodNotAllowed(res)
}
const createCheckoutSession = async (userId: string) => {
if (!process.env.STRIPE_SECRET_KEY)
throw Error('STRIPE_SECRET_KEY var is missing')
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2022-08-01',
})
const claimableCustomPlan = await prisma.claimableCustomPlan.findFirst({
where: { workspace: { members: { some: { userId } } } },
})
if (!claimableCustomPlan) return null
return stripe.checkout.sessions.create({
success_url: `${process.env.NEXTAUTH_URL}/typebots?stripe=${Plan.CUSTOM}&success=true`,
cancel_url: `${process.env.NEXTAUTH_URL}/typebots?stripe=cancel`,
mode: 'subscription',
metadata: {
claimableCustomPlanId: claimableCustomPlan.id,
},
currency: claimableCustomPlan.currency,
automatic_tax: { enabled: true },
line_items: [
{
price_data: {
currency: claimableCustomPlan.currency,
tax_behavior: 'exclusive',
recurring: { interval: 'month' },
product_data: {
name: claimableCustomPlan.name,
description: claimableCustomPlan.description ?? undefined,
},
unit_amount: claimableCustomPlan.price * 100,
},
quantity: 1,
},
],
})
}
export default withSentry(handler)

View File

@ -40,31 +40,58 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
switch (event.type) { switch (event.type) {
case 'checkout.session.completed': { case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session const session = event.data.object as Stripe.Checkout.Session
const { workspaceId, plan, additionalChats, additionalStorage } = const metadata = session.metadata as unknown as
session.metadata as unknown as { | {
plan: 'STARTER' | 'PRO' plan: 'STARTER' | 'PRO'
additionalChats: string additionalChats: string
additionalStorage: string additionalStorage: string
workspaceId: string workspaceId: string
} }
| { claimableCustomPlanId: string }
if ('plan' in metadata) {
const { workspaceId, plan, additionalChats, additionalStorage } =
metadata
if (!workspaceId || !plan || !additionalChats || !additionalStorage)
return res
.status(500)
.send({ message: `Couldn't retrieve valid metadata` })
await prisma.workspace.update({
where: { id: workspaceId },
data: {
plan: plan,
stripeId: session.customer as string,
additionalChatsIndex: parseInt(additionalChats),
additionalStorageIndex: parseInt(additionalStorage),
chatsLimitFirstEmailSentAt: null,
chatsLimitSecondEmailSentAt: null,
storageLimitFirstEmailSentAt: null,
storageLimitSecondEmailSentAt: null,
},
})
} else {
const { claimableCustomPlanId } = metadata
if (!claimableCustomPlanId)
return res
.status(500)
.send({ message: `Couldn't retrieve valid metadata` })
const { workspaceId, chatsLimit, seatsLimit, storageLimit } =
await prisma.claimableCustomPlan.update({
where: { id: claimableCustomPlanId },
data: { claimedAt: new Date() },
})
await prisma.workspace.update({
where: { id: workspaceId },
data: {
plan: Plan.CUSTOM,
stripeId: session.customer as string,
customChatsLimit: chatsLimit,
customStorageLimit: storageLimit,
customSeatsLimit: seatsLimit,
},
})
}
if (!workspaceId || !plan || !additionalChats || !additionalStorage)
return res
.status(500)
.send({ message: `Couldn't retrieve valid metadata` })
await prisma.workspace.update({
where: { id: workspaceId },
data: {
plan: plan,
stripeId: session.customer as string,
additionalChatsIndex: parseInt(additionalChats),
additionalStorageIndex: parseInt(additionalStorage),
chatsLimitFirstEmailSentAt: null,
chatsLimitSecondEmailSentAt: null,
storageLimitFirstEmailSentAt: null,
storageLimitSecondEmailSentAt: null,
},
})
return res.status(200).send({ message: 'workspace upgraded in DB' }) return res.status(200).send({ message: 'workspace upgraded in DB' })
} }
case 'customer.subscription.deleted': { case 'customer.subscription.deleted': {

View File

@ -4,7 +4,7 @@ import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next' import { NextApiRequest, NextApiResponse } from 'next'
import { forbidden, methodNotAllowed, notAuthenticated } from 'utils/api' import { forbidden, methodNotAllowed, notAuthenticated } from 'utils/api'
import { getAuthenticatedUser } from 'services/api/utils' import { getAuthenticatedUser } from 'services/api/utils'
import { env, seatsLimit } from 'utils' import { env, getSeatsLimit } from 'utils'
import { sendWorkspaceMemberInvitationEmail } from 'emails' import { sendWorkspaceMemberInvitationEmail } from 'emails'
const handler = async (req: NextApiRequest, res: NextApiResponse) => { const handler = async (req: NextApiRequest, res: NextApiResponse) => {
@ -70,7 +70,8 @@ const checkIfSeatsLimitReached = async (workspace: Workspace) => {
const existingMembersCount = await prisma.memberInWorkspace.count({ const existingMembersCount = await prisma.memberInWorkspace.count({
where: { workspaceId: workspace.id }, where: { workspaceId: workspace.id },
}) })
return existingMembersCount >= seatsLimit[workspace.plan].totalIncluded
return existingMembersCount >= getSeatsLimit(workspace)
} }
export default withSentry(handler) export default withSentry(handler)

View File

@ -41,12 +41,14 @@ const ResultsPage = () => {
getChatsLimit({ getChatsLimit({
additionalChatsIndex: workspace.additionalChatsIndex, additionalChatsIndex: workspace.additionalChatsIndex,
plan: workspace.plan, plan: workspace.plan,
customChatsLimit: workspace.customChatsLimit,
})) * })) *
100 100
) )
}, [ }, [
usageData?.totalChatsUsed, usageData?.totalChatsUsed,
workspace?.additionalChatsIndex, workspace?.additionalChatsIndex,
workspace?.customChatsLimit,
workspace?.plan, workspace?.plan,
]) ])
@ -60,12 +62,14 @@ const ResultsPage = () => {
getStorageLimit({ getStorageLimit({
additionalStorageIndex: workspace.additionalStorageIndex, additionalStorageIndex: workspace.additionalStorageIndex,
plan: workspace.plan, plan: workspace.plan,
customStorageLimit: workspace.customStorageLimit,
})) * })) *
100 100
) )
}, [ }, [
usageData?.totalStorageUsed, usageData?.totalStorageUsed,
workspace?.additionalStorageIndex, workspace?.additionalStorageIndex,
workspace?.customStorageLimit,
workspace?.plan, workspace?.plan,
]) ])

View File

@ -11,3 +11,4 @@ SMTP_PASSWORD=Ty9BcwCBrK6w8AG2hx
STRIPE_TEST_PUBLIC_KEY= STRIPE_TEST_PUBLIC_KEY=
STRIPE_TEST_SECRET_KEY= STRIPE_TEST_SECRET_KEY=
STRIPE_STARTER_PRICE_ID=

View File

@ -1,4 +1,10 @@
import { CollaborationType, DashboardFolder, PrismaClient, Workspace } from 'db' import {
CollaborationType,
DashboardFolder,
Prisma,
PrismaClient,
Workspace,
} from 'db'
import Stripe from 'stripe' import Stripe from 'stripe'
import { proWorkspaceId } from 'utils/playwright/databaseSetup' import { proWorkspaceId } from 'utils/playwright/databaseSetup'
@ -74,3 +80,10 @@ export const createFolder = (workspaceId: string, name: string) =>
name, name,
}, },
}) })
export const createClaimableCustomPlan = async (
data: Prisma.ClaimableCustomPlanUncheckedCreateInput
) =>
prisma.claimableCustomPlan.create({
data,
})

View File

@ -1,7 +1,10 @@
import test, { expect } from '@playwright/test' import test, { expect } from '@playwright/test'
import cuid from 'cuid' import cuid from 'cuid'
import { Plan } from 'db' import { Plan } from 'db'
import { addSubscriptionToWorkspace } from 'playwright/services/databaseActions' import {
addSubscriptionToWorkspace,
createClaimableCustomPlan,
} from 'playwright/services/databaseActions'
import { import {
createTypebots, createTypebots,
createWorkspaces, createWorkspaces,
@ -12,6 +15,7 @@ import {
const usageWorkspaceId = cuid() const usageWorkspaceId = cuid()
const usageTypebotId = cuid() const usageTypebotId = cuid()
const planChangeWorkspaceId = cuid() const planChangeWorkspaceId = cuid()
const enterpriseWorkspaceId = cuid()
test.beforeAll(async () => { test.beforeAll(async () => {
await createWorkspaces([ await createWorkspaces([
@ -24,12 +28,20 @@ test.beforeAll(async () => {
id: planChangeWorkspaceId, id: planChangeWorkspaceId,
name: 'Plan Change Workspace', name: 'Plan Change Workspace',
}, },
{
id: enterpriseWorkspaceId,
name: 'Enterprise Workspace',
},
]) ])
await createTypebots([{ id: usageTypebotId, workspaceId: usageWorkspaceId }]) await createTypebots([{ id: usageTypebotId, workspaceId: usageWorkspaceId }])
}) })
test.afterAll(async () => { test.afterAll(async () => {
await deleteWorkspaces([usageWorkspaceId, planChangeWorkspaceId]) await deleteWorkspaces([
usageWorkspaceId,
planChangeWorkspaceId,
enterpriseWorkspaceId,
])
}) })
test('should display valid usage', async ({ page }) => { test('should display valid usage', async ({ page }) => {
@ -38,14 +50,37 @@ test('should display valid usage', async ({ page }) => {
await page.click('text=Billing & Usage') await page.click('text=Billing & Usage')
await expect(page.locator('text="/ 10,000"')).toBeVisible() await expect(page.locator('text="/ 10,000"')).toBeVisible()
await expect(page.locator('text="/ 10 GB"')).toBeVisible() await expect(page.locator('text="/ 10 GB"')).toBeVisible()
await page.getByText('Members', { exact: true }).click()
await expect(
page.getByRole('heading', { name: 'Members (1/5)' })
).toBeVisible()
await page.click('text=Pro workspace', { force: true }) await page.click('text=Pro workspace', { force: true })
await page.click('text=Pro workspace') await page.click('text=Pro workspace')
await page.click('text="Custom workspace"')
await page.click('text=Settings & Members')
await page.click('text=Billing & Usage')
await expect(page.locator('text="/ 100,000"')).toBeVisible()
await expect(page.locator('text="/ 50 GB"')).toBeVisible()
await expect(page.getByText('Upgrade to Starter')).toBeHidden()
await expect(page.getByText('Upgrade to Pro')).toBeHidden()
await expect(page.getByText('Need custom limits?')).toBeHidden()
await page.getByText('Members', { exact: true }).click()
await expect(
page.getByRole('heading', { name: 'Members (1/20)' })
).toBeVisible()
await page.click('text=Custom workspace', { force: true })
await page.click('text=Custom workspace')
await page.click('text="Free workspace"') await page.click('text="Free workspace"')
await page.click('text=Settings & Members') await page.click('text=Settings & Members')
await page.click('text=Billing & Usage') await page.click('text=Billing & Usage')
await expect(page.locator('text="/ 300"')).toBeVisible() await expect(page.locator('text="/ 300"')).toBeVisible()
await expect(page.locator('text="Storage"')).toBeHidden() await expect(page.locator('text="Storage"')).toBeHidden()
await page.getByText('Members', { exact: true }).click()
await expect(
page.getByRole('heading', { name: 'Members (1/1)' })
).toBeVisible()
await page.click('text=Free workspace', { force: true }) await page.click('text=Free workspace', { force: true })
await injectFakeResults({ await injectFakeResults({
@ -189,3 +224,30 @@ test('should display invoices', async ({ page }) => {
await expect(page.locator('tr')).toHaveCount(2) await expect(page.locator('tr')).toHaveCount(2)
await expect(page.locator('text="€39.00"')).toBeVisible() await expect(page.locator('text="€39.00"')).toBeVisible()
}) })
test('custom plans should work', async ({ page }) => {
await page.goto('/typebots')
await page.click('text=Pro workspace')
await page.click('text=Enterprise Workspace')
await page.click('text=Settings & Members')
await page.click('text=Billing & Usage')
await expect(page.getByTestId('current-subscription')).toHaveText(
'Current workspace subscription: Free'
)
await createClaimableCustomPlan({
currency: 'usd',
price: 239,
workspaceId: enterpriseWorkspaceId,
chatsLimit: 100000,
storageLimit: 50,
seatsLimit: 10,
name: 'Acme custom plan',
description: 'Description of the deal',
})
await page.goto('/api/stripe/custom-plan-checkout')
await expect(page.getByRole('list').getByText('$239.00')).toBeVisible()
await expect(page.getByText('Subscribe to Acme custom plan')).toBeVisible()
await expect(page.getByText('Description of the deal')).toBeVisible()
})

View File

@ -104,6 +104,9 @@ test('can manage members', async ({ page }) => {
).toHaveAttribute('value', '') ).toHaveAttribute('value', '')
await expect(page.locator('text="guest@email.com"')).toBeVisible() await expect(page.locator('text="guest@email.com"')).toBeVisible()
await expect(page.locator('text="Pending"')).toBeVisible() await expect(page.locator('text="Pending"')).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Members (2/5)' })
).toBeVisible()
await page.fill( await page.fill(
'input[placeholder="colleague@company.com"]', 'input[placeholder="colleague@company.com"]',
'other-user@email.com' 'other-user@email.com'
@ -116,6 +119,9 @@ test('can manage members', async ({ page }) => {
).toHaveAttribute('value', '') ).toHaveAttribute('value', '')
await expect(page.locator('text="other-user@email.com"')).toBeVisible() await expect(page.locator('text="other-user@email.com"')).toBeVisible()
await expect(page.locator('text="James Doe"')).toBeVisible() await expect(page.locator('text="James Doe"')).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Members (3/5)' })
).toBeVisible()
await page.click('text="other-user@email.com"') await page.click('text="other-user@email.com"')
await page.click('button >> text="Member"') await page.click('button >> text="Member"')

View File

@ -31,6 +31,9 @@ export const createNewWorkspace = async (
| 'chatsLimitSecondEmailSentAt' | 'chatsLimitSecondEmailSentAt'
| 'storageLimitFirstEmailSentAt' | 'storageLimitFirstEmailSentAt'
| 'storageLimitSecondEmailSentAt' | 'storageLimitSecondEmailSentAt'
| 'customChatsLimit'
| 'customStorageLimit'
| 'customSeatsLimit'
> >
) => ) =>
sendRequest<{ sendRequest<{
@ -77,4 +80,6 @@ export const isFreePlan = (workspace?: Pick<Workspace, 'plan'>) =>
export const isWorkspaceProPlan = (workspace?: Pick<Workspace, 'plan'>) => export const isWorkspaceProPlan = (workspace?: Pick<Workspace, 'plan'>) =>
isDefined(workspace) && isDefined(workspace) &&
(workspace.plan === Plan.PRO || workspace.plan === Plan.LIFETIME) (workspace.plan === Plan.PRO ||
workspace.plan === Plan.LIFETIME ||
workspace.plan === Plan.CUSTOM)

View File

@ -69,6 +69,7 @@ const checkStorageLimit = async (typebotId: string) => {
plan: true, plan: true,
storageLimitFirstEmailSentAt: true, storageLimitFirstEmailSentAt: true,
storageLimitSecondEmailSentAt: true, storageLimitSecondEmailSentAt: true,
customStorageLimit: true,
}, },
}, },
}, },

View File

@ -48,6 +48,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
additionalChatsIndex: true, additionalChatsIndex: true,
chatsLimitFirstEmailSentAt: true, chatsLimitFirstEmailSentAt: true,
chatsLimitSecondEmailSentAt: true, chatsLimitSecondEmailSentAt: true,
customChatsLimit: true,
}, },
}, },
}, },
@ -71,6 +72,7 @@ const checkChatsUsage = async (
| 'additionalChatsIndex' | 'additionalChatsIndex'
| 'chatsLimitFirstEmailSentAt' | 'chatsLimitFirstEmailSentAt'
| 'chatsLimitSecondEmailSentAt' | 'chatsLimitSecondEmailSentAt'
| 'customChatsLimit'
> >
) => { ) => {
const chatsLimit = getChatsLimit(workspace) const chatsLimit = getChatsLimit(workspace)

View File

@ -0,0 +1,30 @@
-- AlterEnum
ALTER TYPE "Plan" ADD VALUE 'CUSTOM';
-- AlterTable
ALTER TABLE "Workspace" ADD COLUMN "customChatsLimit" INTEGER,
ADD COLUMN "customSeatsLimit" INTEGER,
ADD COLUMN "customStorageLimit" INTEGER;
-- CreateTable
CREATE TABLE "ClaimableCustomPlan" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"claimedAt" TIMESTAMP(3),
"name" TEXT NOT NULL,
"description" TEXT,
"price" INTEGER NOT NULL,
"currency" TEXT NOT NULL,
"workspaceId" TEXT NOT NULL,
"chatsLimit" INTEGER NOT NULL,
"storageLimit" INTEGER NOT NULL,
"seatsLimit" INTEGER NOT NULL,
CONSTRAINT "ClaimableCustomPlan_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ClaimableCustomPlan_workspaceId_key" ON "ClaimableCustomPlan"("workspaceId");
-- AddForeignKey
ALTER TABLE "ClaimableCustomPlan" ADD CONSTRAINT "ClaimableCustomPlan_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -84,6 +84,10 @@ model Workspace {
storageLimitFirstEmailSentAt DateTime? storageLimitFirstEmailSentAt DateTime?
chatsLimitSecondEmailSentAt DateTime? chatsLimitSecondEmailSentAt DateTime?
storageLimitSecondEmailSentAt DateTime? storageLimitSecondEmailSentAt DateTime?
claimableCustomPlan ClaimableCustomPlan?
customChatsLimit Int?
customStorageLimit Int?
customSeatsLimit Int?
} }
model MemberInWorkspace { model MemberInWorkspace {
@ -263,6 +267,21 @@ model Webhook {
typebot Typebot @relation(fields: [typebotId], references: [id], onDelete: Cascade) typebot Typebot @relation(fields: [typebotId], references: [id], onDelete: Cascade)
} }
model ClaimableCustomPlan {
id String @id @default(cuid())
createdAt DateTime @default(now())
claimedAt DateTime?
name String
description String?
price Int
currency String
workspaceId String @unique
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
chatsLimit Int
storageLimit Int
seatsLimit Int
}
enum WorkspaceRole { enum WorkspaceRole {
ADMIN ADMIN
MEMBER MEMBER
@ -280,6 +299,7 @@ enum Plan {
PRO PRO
LIFETIME LIFETIME
OFFERED OFFERED
CUSTOM
} }
enum CollaborationType { enum CollaborationType {

View File

@ -1,4 +1,4 @@
import { Plan, PrismaClient, User, Workspace, WorkspaceRole } from 'db' import { Plan, Prisma, PrismaClient, User, Workspace, WorkspaceRole } from 'db'
import cuid from 'cuid' import cuid from 'cuid'
import { Typebot, Webhook } from 'models' import { Typebot, Webhook } from 'models'
import { readFileSync } from 'fs' import { readFileSync } from 'fs'
@ -166,3 +166,13 @@ export const updateTypebot = async (
data: partialTypebot, data: partialTypebot,
}) })
} }
export const updateWorkspace = async (
id: string,
data: Prisma.WorkspaceUncheckedUpdateManyInput
) => {
await prisma.workspace.updateMany({
where: { id: proWorkspaceId },
data,
})
}

View File

@ -13,17 +13,16 @@ export const proWorkspaceId = 'proWorkspace'
export const freeWorkspaceId = 'freeWorkspace' export const freeWorkspaceId = 'freeWorkspace'
export const starterWorkspaceId = 'starterWorkspace' export const starterWorkspaceId = 'starterWorkspace'
export const lifetimeWorkspaceId = 'lifetimeWorkspaceId' export const lifetimeWorkspaceId = 'lifetimeWorkspaceId'
export const customWorkspaceId = 'customWorkspaceId'
const setupWorkspaces = async () => { const setupWorkspaces = async () => {
await prisma.workspace.create({
data: {
id: freeWorkspaceId,
name: 'Free workspace',
plan: Plan.FREE,
},
})
await prisma.workspace.createMany({ await prisma.workspace.createMany({
data: [ data: [
{
id: freeWorkspaceId,
name: 'Free workspace',
plan: Plan.FREE,
},
{ {
id: starterWorkspaceId, id: starterWorkspaceId,
name: 'Starter workspace', name: 'Starter workspace',
@ -40,6 +39,14 @@ const setupWorkspaces = async () => {
name: 'Lifetime workspace', name: 'Lifetime workspace',
plan: Plan.LIFETIME, plan: Plan.LIFETIME,
}, },
{
id: customWorkspaceId,
name: 'Custom workspace',
plan: Plan.CUSTOM,
customChatsLimit: 100000,
customStorageLimit: 50,
customSeatsLimit: 20,
},
], ],
}) })
} }
@ -99,6 +106,11 @@ export const setupUsers = async () => {
userId, userId,
workspaceId: lifetimeWorkspaceId, workspaceId: lifetimeWorkspaceId,
}, },
{
role: WorkspaceRole.ADMIN,
userId,
workspaceId: customWorkspaceId,
},
], ],
}) })
} }

View File

@ -48,7 +48,7 @@ export const storageLimit = {
} as const } as const
export const seatsLimit = { export const seatsLimit = {
[Plan.FREE]: { totalIncluded: 0 }, [Plan.FREE]: { totalIncluded: 1 },
[Plan.STARTER]: { [Plan.STARTER]: {
totalIncluded: 2, totalIncluded: 2,
}, },
@ -62,7 +62,10 @@ export const seatsLimit = {
export const getChatsLimit = ({ export const getChatsLimit = ({
plan, plan,
additionalChatsIndex, additionalChatsIndex,
}: Pick<Workspace, 'additionalChatsIndex' | 'plan'>) => { customChatsLimit,
}: Pick<Workspace, 'additionalChatsIndex' | 'plan' | 'customChatsLimit'>) => {
if (plan === Plan.CUSTOM)
return customChatsLimit ?? chatsLimit[Plan.FREE].totalIncluded
const { totalIncluded } = chatsLimit[plan] const { totalIncluded } = chatsLimit[plan]
const increaseStep = const increaseStep =
plan === Plan.STARTER || plan === Plan.PRO plan === Plan.STARTER || plan === Plan.PRO
@ -75,7 +78,13 @@ export const getChatsLimit = ({
export const getStorageLimit = ({ export const getStorageLimit = ({
plan, plan,
additionalStorageIndex, additionalStorageIndex,
}: Pick<Workspace, 'additionalStorageIndex' | 'plan'>) => { customStorageLimit,
}: Pick<
Workspace,
'additionalStorageIndex' | 'plan' | 'customStorageLimit'
>) => {
if (plan === Plan.CUSTOM)
return customStorageLimit ?? storageLimit[Plan.FREE].totalIncluded
const { totalIncluded } = storageLimit[plan] const { totalIncluded } = storageLimit[plan]
const increaseStep = const increaseStep =
plan === Plan.STARTER || plan === Plan.PRO plan === Plan.STARTER || plan === Plan.PRO
@ -84,6 +93,15 @@ export const getStorageLimit = ({
return totalIncluded + increaseStep.amount * additionalStorageIndex return totalIncluded + increaseStep.amount * additionalStorageIndex
} }
export const getSeatsLimit = ({
plan,
customSeatsLimit,
}: Pick<Workspace, 'plan' | 'customSeatsLimit'>) => {
if (plan === Plan.CUSTOM)
return customSeatsLimit ?? seatsLimit[Plan.FREE].totalIncluded
return seatsLimit[plan].totalIncluded
}
export const computePrice = ( export const computePrice = (
plan: Plan, plan: Plan,
selectedTotalChatsIndex: number, selectedTotalChatsIndex: number,