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

@ -10,4 +10,5 @@ SMTP_USERNAME=tobin.tillman65@ethereal.email
SMTP_PASSWORD=Ty9BcwCBrK6w8AG2hx
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 { 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)