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 { useWorkspace } from 'contexts/WorkspaceContext'
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 />}
</Stack>

View File

@ -49,7 +49,7 @@ export const AddMemberForm = ({
}
return (
<HStack as="form" onSubmit={handleInvitationSubmit} pb="4">
<HStack as="form" onSubmit={handleInvitationSubmit}>
<Input
placeholder="colleague@company.com"
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 { useUser } from 'contexts/UserContext'
import { useWorkspace } from 'contexts/WorkspaceContext'
@ -12,6 +18,7 @@ import {
updateMember,
useMembers,
} from 'services/workspace'
import { getSeatsLimit } from 'utils'
import { AddMemberForm } from './AddMemberForm'
import { checkCanInviteMember } from './helpers'
import { MemberItem } from './MemberItem'
@ -77,9 +84,12 @@ export const MembersList = () => {
})
}
const currentMembersCount = members.length + invitations.length
const canInviteNewMember = checkCanInviteMember({
plan: workspace?.plan,
currentMembersCount: [...members, ...invitations].length,
customSeatsLimit: workspace?.customSeatsLimit,
currentMembersCount,
})
return (
@ -92,6 +102,11 @@ export const MembersList = () => {
`}
/>
)}
{workspace && (
<Heading fontSize="2xl">
Members ({currentMembersCount}/{getSeatsLimit(workspace)})
</Heading>
)}
{workspace?.id && canEdit && (
<AddMemberForm
workspaceId={workspace.id}

View File

@ -1,14 +1,19 @@
import { Plan } from 'db'
import { seatsLimit } from 'utils'
import { getSeatsLimit } from 'utils'
export function checkCanInviteMember({
plan,
customSeatsLimit,
currentMembersCount,
}: {
plan?: Plan
customSeatsLimit?: number | null
currentMembersCount?: number
}) {
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 { StripeClimateLogo } from 'assets/logos/StripeClimateLogo'
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
import { useUser } from 'contexts/UserContext'
import { useWorkspace } from 'contexts/WorkspaceContext'
@ -65,20 +64,6 @@ export const ChangePlanForm = () => {
return (
<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">
<StarterPlanContent
initialChatsLimitIndex={

View File

@ -7,9 +7,13 @@ export const planColorSchemes: Record<Plan, ThemeTypings['colorSchemes']> = {
[Plan.OFFERED]: 'orange',
[Plan.STARTER]: 'orange',
[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) {
case Plan.LIFETIME: {
return (
@ -45,7 +49,7 @@ export const PlanTag = ({ plan, ...props }: { plan?: Plan } & TagProps) => {
</Tag>
)
}
default: {
case Plan.FREE: {
return (
<Tag
colorScheme={planColorSchemes[Plan.FREE]}
@ -56,5 +60,16 @@ export const PlanTag = ({ plan, ...props }: { plan?: Plan } & TagProps) => {
</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
}
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) => {
const { query } = useRouter()
const { user } = useUser()
@ -97,10 +113,11 @@ export const WorkspaceContext = ({ children }: WorkspaceContextProps) => {
setCurrentWorkspace(newWorkspace)
}
const createWorkspace = async (name?: string) => {
const createWorkspace = async (userFullName?: string) => {
if (!workspaces) return
const newWorkspaceName = getNewWorkspaceName(userFullName, workspaces)
const { data, error } = await createNewWorkspace({
name: name ? `${name}'s workspace` : 'My workspace',
name: newWorkspaceName,
plan: Plan.FREE,
})
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) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session
const { workspaceId, plan, additionalChats, additionalStorage } =
session.metadata as unknown as {
plan: 'STARTER' | 'PRO'
additionalChats: string
additionalStorage: string
workspaceId: string
}
const metadata = session.metadata as unknown as
| {
plan: 'STARTER' | 'PRO'
additionalChats: string
additionalStorage: 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' })
}
case 'customer.subscription.deleted': {

View File

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

View File

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

View File

@ -11,3 +11,4 @@ SMTP_PASSWORD=Ty9BcwCBrK6w8AG2hx
STRIPE_TEST_PUBLIC_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 { proWorkspaceId } from 'utils/playwright/databaseSetup'
@ -74,3 +80,10 @@ export const createFolder = (workspaceId: string, name: string) =>
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 cuid from 'cuid'
import { Plan } from 'db'
import { addSubscriptionToWorkspace } from 'playwright/services/databaseActions'
import {
addSubscriptionToWorkspace,
createClaimableCustomPlan,
} from 'playwright/services/databaseActions'
import {
createTypebots,
createWorkspaces,
@ -12,6 +15,7 @@ import {
const usageWorkspaceId = cuid()
const usageTypebotId = cuid()
const planChangeWorkspaceId = cuid()
const enterpriseWorkspaceId = cuid()
test.beforeAll(async () => {
await createWorkspaces([
@ -24,12 +28,20 @@ test.beforeAll(async () => {
id: planChangeWorkspaceId,
name: 'Plan Change Workspace',
},
{
id: enterpriseWorkspaceId,
name: 'Enterprise Workspace',
},
])
await createTypebots([{ id: usageTypebotId, workspaceId: usageWorkspaceId }])
})
test.afterAll(async () => {
await deleteWorkspaces([usageWorkspaceId, planChangeWorkspaceId])
await deleteWorkspaces([
usageWorkspaceId,
planChangeWorkspaceId,
enterpriseWorkspaceId,
])
})
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 expect(page.locator('text="/ 10,000"')).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')
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=Settings & Members')
await page.click('text=Billing & Usage')
await expect(page.locator('text="/ 300"')).toBeVisible()
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 injectFakeResults({
@ -189,3 +224,30 @@ test('should display invoices', async ({ page }) => {
await expect(page.locator('tr')).toHaveCount(2)
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', '')
await expect(page.locator('text="guest@email.com"')).toBeVisible()
await expect(page.locator('text="Pending"')).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Members (2/5)' })
).toBeVisible()
await page.fill(
'input[placeholder="colleague@company.com"]',
'other-user@email.com'
@ -116,6 +119,9 @@ test('can manage members', async ({ page }) => {
).toHaveAttribute('value', '')
await expect(page.locator('text="other-user@email.com"')).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('button >> text="Member"')

View File

@ -31,6 +31,9 @@ export const createNewWorkspace = async (
| 'chatsLimitSecondEmailSentAt'
| 'storageLimitFirstEmailSentAt'
| 'storageLimitSecondEmailSentAt'
| 'customChatsLimit'
| 'customStorageLimit'
| 'customSeatsLimit'
>
) =>
sendRequest<{
@ -77,4 +80,6 @@ export const isFreePlan = (workspace?: Pick<Workspace, 'plan'>) =>
export const isWorkspaceProPlan = (workspace?: Pick<Workspace, 'plan'>) =>
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,
storageLimitFirstEmailSentAt: true,
storageLimitSecondEmailSentAt: true,
customStorageLimit: true,
},
},
},

View File

@ -48,6 +48,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
additionalChatsIndex: true,
chatsLimitFirstEmailSentAt: true,
chatsLimitSecondEmailSentAt: true,
customChatsLimit: true,
},
},
},
@ -71,6 +72,7 @@ const checkChatsUsage = async (
| 'additionalChatsIndex'
| 'chatsLimitFirstEmailSentAt'
| 'chatsLimitSecondEmailSentAt'
| 'customChatsLimit'
>
) => {
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?
chatsLimitSecondEmailSentAt DateTime?
storageLimitSecondEmailSentAt DateTime?
claimableCustomPlan ClaimableCustomPlan?
customChatsLimit Int?
customStorageLimit Int?
customSeatsLimit Int?
}
model MemberInWorkspace {
@ -263,6 +267,21 @@ model Webhook {
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 {
ADMIN
MEMBER
@ -280,6 +299,7 @@ enum Plan {
PRO
LIFETIME
OFFERED
CUSTOM
}
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 { Typebot, Webhook } from 'models'
import { readFileSync } from 'fs'
@ -166,3 +166,13 @@ export const updateTypebot = async (
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 starterWorkspaceId = 'starterWorkspace'
export const lifetimeWorkspaceId = 'lifetimeWorkspaceId'
export const customWorkspaceId = 'customWorkspaceId'
const setupWorkspaces = async () => {
await prisma.workspace.create({
data: {
id: freeWorkspaceId,
name: 'Free workspace',
plan: Plan.FREE,
},
})
await prisma.workspace.createMany({
data: [
{
id: freeWorkspaceId,
name: 'Free workspace',
plan: Plan.FREE,
},
{
id: starterWorkspaceId,
name: 'Starter workspace',
@ -40,6 +39,14 @@ const setupWorkspaces = async () => {
name: 'Lifetime workspace',
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,
workspaceId: lifetimeWorkspaceId,
},
{
role: WorkspaceRole.ADMIN,
userId,
workspaceId: customWorkspaceId,
},
],
})
}

View File

@ -48,7 +48,7 @@ export const storageLimit = {
} as const
export const seatsLimit = {
[Plan.FREE]: { totalIncluded: 0 },
[Plan.FREE]: { totalIncluded: 1 },
[Plan.STARTER]: {
totalIncluded: 2,
},
@ -62,7 +62,10 @@ export const seatsLimit = {
export const getChatsLimit = ({
plan,
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 increaseStep =
plan === Plan.STARTER || plan === Plan.PRO
@ -75,7 +78,13 @@ export const getChatsLimit = ({
export const getStorageLimit = ({
plan,
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 increaseStep =
plan === Plan.STARTER || plan === Plan.PRO
@ -84,6 +93,15 @@ export const getStorageLimit = ({
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 = (
plan: Plan,
selectedTotalChatsIndex: number,