diff --git a/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/BillingContent.tsx b/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/BillingContent.tsx
index d10af211f..4c4b8c082 100644
--- a/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/BillingContent.tsx
+++ b/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingContent/BillingContent.tsx
@@ -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 &&
+
+
+
+ Typebot is contributing 1% of your subscription to remove CO₂ from
+ the atmosphere.{' '}
+
+ More info.
+
+
+
+ {workspace.plan !== Plan.CUSTOM &&
+ workspace.plan !== Plan.LIFETIME &&
workspace.plan !== Plan.OFFERED && }
diff --git a/apps/builder/components/dashboard/WorkspaceSettingsModal/MembersList/AddMemberForm.tsx b/apps/builder/components/dashboard/WorkspaceSettingsModal/MembersList/AddMemberForm.tsx
index 02af2e5ec..1ded70199 100644
--- a/apps/builder/components/dashboard/WorkspaceSettingsModal/MembersList/AddMemberForm.tsx
+++ b/apps/builder/components/dashboard/WorkspaceSettingsModal/MembersList/AddMemberForm.tsx
@@ -49,7 +49,7 @@ export const AddMemberForm = ({
}
return (
-
+
{
})
}
+ 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 && (
+
+ Members ({currentMembersCount}/{getSeatsLimit(workspace)})
+
+ )}
{workspace?.id && canEdit && (
currentMembersCount
+ return (
+ getSeatsLimit({ plan, customSeatsLimit: customSeatsLimit ?? null }) >
+ currentMembersCount
+ )
}
diff --git a/apps/builder/components/shared/ChangePlanForm/ChangePlanForm.tsx b/apps/builder/components/shared/ChangePlanForm/ChangePlanForm.tsx
index 2f7ddc9c3..2c54f77b2 100644
--- a/apps/builder/components/shared/ChangePlanForm/ChangePlanForm.tsx
+++ b/apps/builder/components/shared/ChangePlanForm/ChangePlanForm.tsx
@@ -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 (
-
-
-
- Typebot is contributing 1% of your subscription to remove CO₂ from the
- atmosphere.{' '}
-
- More info.
-
-
-
= {
[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) => {
)
}
- default: {
+ case Plan.FREE: {
return (
{
)
}
+ case Plan.CUSTOM: {
+ return (
+
+ Custom
+
+ )
+ }
}
}
diff --git a/apps/builder/contexts/WorkspaceContext.tsx b/apps/builder/contexts/WorkspaceContext.tsx
index 495c4507a..99bc31e40 100644
--- a/apps/builder/contexts/WorkspaceContext.tsx
+++ b/apps/builder/contexts/WorkspaceContext.tsx
@@ -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
diff --git a/apps/builder/pages/api/stripe/custom-plan-checkout.ts b/apps/builder/pages/api/stripe/custom-plan-checkout.ts
new file mode 100644
index 000000000..1be134fb2
--- /dev/null
+++ b/apps/builder/pages/api/stripe/custom-plan-checkout.ts
@@ -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)
diff --git a/apps/builder/pages/api/stripe/webhook.ts b/apps/builder/pages/api/stripe/webhook.ts
index a4fac578f..600ec5051 100644
--- a/apps/builder/pages/api/stripe/webhook.ts
+++ b/apps/builder/pages/api/stripe/webhook.ts
@@ -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': {
diff --git a/apps/builder/pages/api/workspaces/[workspaceId]/invitations.ts b/apps/builder/pages/api/workspaces/[workspaceId]/invitations.ts
index 1a04c252b..82898be0a 100644
--- a/apps/builder/pages/api/workspaces/[workspaceId]/invitations.ts
+++ b/apps/builder/pages/api/workspaces/[workspaceId]/invitations.ts
@@ -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)
diff --git a/apps/builder/pages/typebots/[typebotId]/results.tsx b/apps/builder/pages/typebots/[typebotId]/results.tsx
index d78a7c539..d22a23d4c 100644
--- a/apps/builder/pages/typebots/[typebotId]/results.tsx
+++ b/apps/builder/pages/typebots/[typebotId]/results.tsx
@@ -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,
])
diff --git a/apps/builder/playwright/.env.example b/apps/builder/playwright/.env.example
index 0ff8814dd..691105e0f 100644
--- a/apps/builder/playwright/.env.example
+++ b/apps/builder/playwright/.env.example
@@ -10,4 +10,5 @@ SMTP_USERNAME=tobin.tillman65@ethereal.email
SMTP_PASSWORD=Ty9BcwCBrK6w8AG2hx
STRIPE_TEST_PUBLIC_KEY=
-STRIPE_TEST_SECRET_KEY=
\ No newline at end of file
+STRIPE_TEST_SECRET_KEY=
+STRIPE_STARTER_PRICE_ID=
\ No newline at end of file
diff --git a/apps/builder/playwright/services/databaseActions.ts b/apps/builder/playwright/services/databaseActions.ts
index 2790d98e8..14cf1e413 100644
--- a/apps/builder/playwright/services/databaseActions.ts
+++ b/apps/builder/playwright/services/databaseActions.ts
@@ -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,
+ })
diff --git a/apps/builder/playwright/tests/billing.spec.ts b/apps/builder/playwright/tests/billing.spec.ts
index 8f106312e..0d1ac902a 100644
--- a/apps/builder/playwright/tests/billing.spec.ts
+++ b/apps/builder/playwright/tests/billing.spec.ts
@@ -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()
+})
diff --git a/apps/builder/playwright/tests/workspaces.spec.ts b/apps/builder/playwright/tests/workspaces.spec.ts
index 91f9dc0b1..ed4144171 100644
--- a/apps/builder/playwright/tests/workspaces.spec.ts
+++ b/apps/builder/playwright/tests/workspaces.spec.ts
@@ -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"')
diff --git a/apps/builder/services/workspace/workspace.ts b/apps/builder/services/workspace/workspace.ts
index b7d21a628..bf802685c 100644
--- a/apps/builder/services/workspace/workspace.ts
+++ b/apps/builder/services/workspace/workspace.ts
@@ -31,6 +31,9 @@ export const createNewWorkspace = async (
| 'chatsLimitSecondEmailSentAt'
| 'storageLimitFirstEmailSentAt'
| 'storageLimitSecondEmailSentAt'
+ | 'customChatsLimit'
+ | 'customStorageLimit'
+ | 'customSeatsLimit'
>
) =>
sendRequest<{
@@ -77,4 +80,6 @@ export const isFreePlan = (workspace?: Pick) =>
export const isWorkspaceProPlan = (workspace?: Pick) =>
isDefined(workspace) &&
- (workspace.plan === Plan.PRO || workspace.plan === Plan.LIFETIME)
+ (workspace.plan === Plan.PRO ||
+ workspace.plan === Plan.LIFETIME ||
+ workspace.plan === Plan.CUSTOM)
diff --git a/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/storage/upload-url.ts b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/storage/upload-url.ts
index a8d58478b..ab5ef34da 100644
--- a/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/storage/upload-url.ts
+++ b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/storage/upload-url.ts
@@ -69,6 +69,7 @@ const checkStorageLimit = async (typebotId: string) => {
plan: true,
storageLimitFirstEmailSentAt: true,
storageLimitSecondEmailSentAt: true,
+ customStorageLimit: true,
},
},
},
diff --git a/apps/viewer/pages/api/typebots/[typebotId]/results.ts b/apps/viewer/pages/api/typebots/[typebotId]/results.ts
index 9836fb443..f02ec1927 100644
--- a/apps/viewer/pages/api/typebots/[typebotId]/results.ts
+++ b/apps/viewer/pages/api/typebots/[typebotId]/results.ts
@@ -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)
diff --git a/packages/db/prisma/migrations/20221028145148_add_claimable_custom_plan/migration.sql b/packages/db/prisma/migrations/20221028145148_add_claimable_custom_plan/migration.sql
new file mode 100644
index 000000000..d591b2ac0
--- /dev/null
+++ b/packages/db/prisma/migrations/20221028145148_add_claimable_custom_plan/migration.sql
@@ -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;
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index 8fb454247..260e145f1 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -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 {
diff --git a/packages/utils/playwright/databaseActions.ts b/packages/utils/playwright/databaseActions.ts
index 5666bbb71..5c9cb98de 100644
--- a/packages/utils/playwright/databaseActions.ts
+++ b/packages/utils/playwright/databaseActions.ts
@@ -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,
+ })
+}
diff --git a/packages/utils/playwright/databaseSetup.ts b/packages/utils/playwright/databaseSetup.ts
index 025ab26c9..0769f2568 100644
--- a/packages/utils/playwright/databaseSetup.ts
+++ b/packages/utils/playwright/databaseSetup.ts
@@ -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,
+ },
],
})
}
diff --git a/packages/utils/pricing.ts b/packages/utils/pricing.ts
index 1cfb14588..a3939a58a 100644
--- a/packages/utils/pricing.ts
+++ b/packages/utils/pricing.ts
@@ -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) => {
+ customChatsLimit,
+}: Pick) => {
+ 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) => {
+ 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) => {
+ if (plan === Plan.CUSTOM)
+ return customSeatsLimit ?? seatsLimit[Plan.FREE].totalIncluded
+ return seatsLimit[plan].totalIncluded
+}
+
export const computePrice = (
plan: Plan,
selectedTotalChatsIndex: number,