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,