✨ (billing) Implement custom plan
This commit is contained in:
committed by
Baptiste Arnaud
parent
3f7dc79918
commit
385853ca3c
@ -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>
|
||||||
|
|
||||||
|
@ -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"
|
||||||
|
@ -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}
|
||||||
|
@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
@ -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={
|
||||||
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
61
apps/builder/pages/api/stripe/custom-plan-checkout.ts
Normal file
61
apps/builder/pages/api/stripe/custom-plan-checkout.ts
Normal 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)
|
@ -40,14 +40,17 @@ 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)
|
if (!workspaceId || !plan || !additionalChats || !additionalStorage)
|
||||||
return res
|
return res
|
||||||
.status(500)
|
.status(500)
|
||||||
@ -65,6 +68,30 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
storageLimitSecondEmailSentAt: 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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
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': {
|
||||||
|
@ -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)
|
||||||
|
@ -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,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
@ -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=
|
@ -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,
|
||||||
|
})
|
||||||
|
@ -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()
|
||||||
|
})
|
||||||
|
@ -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"')
|
||||||
|
@ -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)
|
||||||
|
@ -69,6 +69,7 @@ const checkStorageLimit = async (typebotId: string) => {
|
|||||||
plan: true,
|
plan: true,
|
||||||
storageLimitFirstEmailSentAt: true,
|
storageLimitFirstEmailSentAt: true,
|
||||||
storageLimitSecondEmailSentAt: true,
|
storageLimitSecondEmailSentAt: true,
|
||||||
|
customStorageLimit: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -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)
|
||||||
|
@ -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;
|
@ -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 {
|
||||||
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
@ -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({
|
await prisma.workspace.createMany({
|
||||||
data: {
|
data: [
|
||||||
|
{
|
||||||
id: freeWorkspaceId,
|
id: freeWorkspaceId,
|
||||||
name: 'Free workspace',
|
name: 'Free workspace',
|
||||||
plan: Plan.FREE,
|
plan: Plan.FREE,
|
||||||
},
|
},
|
||||||
})
|
|
||||||
await prisma.workspace.createMany({
|
|
||||||
data: [
|
|
||||||
{
|
{
|
||||||
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,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
Reference in New Issue
Block a user