2
0

Add usage-based new pricing plans

This commit is contained in:
Baptiste Arnaud
2022-09-17 16:37:33 +02:00
committed by Baptiste Arnaud
parent 6a1eaea700
commit 898367a33b
144 changed files with 4631 additions and 1624 deletions

View File

@@ -12,6 +12,7 @@ import { withSentry } from '@sentry/nextjs'
import { CustomAdapter } from './adapter'
import { User } from 'db'
import { env, isNotEmpty } from 'utils'
import { mockedUser } from 'services/api/utils'
const providers: Provider[] = []
@@ -98,6 +99,14 @@ if (
}
const handler = (req: NextApiRequest, res: NextApiResponse) => {
if (
req.method === 'GET' &&
req.url === '/api/auth/session' &&
env('E2E_TEST') === 'true'
) {
res.send({ user: mockedUser })
return
}
if (req.method === 'HEAD') {
res.status(200)
return

View File

@@ -52,10 +52,11 @@ export function CustomAdapter(p: PrismaClient): Adapter {
name: data.name
? `${data.name}'s workspace`
: `My workspace`,
plan:
process.env.ADMIN_EMAIL === data.email
? Plan.TEAM
: Plan.FREE,
...(process.env.ADMIN_EMAIL === data.email
? { plan: Plan.LIFETIME }
: {
plan: Plan.FREE,
}),
},
},
},

View File

@@ -15,13 +15,13 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res)
if (req.method === 'GET') {
const workspaceId = req.query.workspaceId as string | undefined
if (!workspaceId) return badRequest(res)
const stripeId = req.query.stripeId as string | undefined
if (!stripeId) return badRequest(res)
if (!process.env.STRIPE_SECRET_KEY)
throw Error('STRIPE_SECRET_KEY var is missing')
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
stripeId,
members: { some: { userId: user.id, role: WorkspaceRole.ADMIN } },
},
})

View File

@@ -1,46 +0,0 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { methodNotAllowed } from 'utils'
import Stripe from 'stripe'
import { withSentry } from '@sentry/nextjs'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'POST') {
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 { email, currency, plan, workspaceId, href } =
typeof req.body === 'string' ? JSON.parse(req.body) : req.body
const session = await stripe.checkout.sessions.create({
success_url: `${href}?stripe=${plan}`,
cancel_url: `${href}?stripe=cancel`,
automatic_tax: { enabled: true },
allow_promotion_codes: true,
customer_email: email,
mode: 'subscription',
metadata: { workspaceId, plan },
line_items: [
{
price: getPrice(plan, currency),
quantity: 1,
},
],
})
return res.status(201).send({ sessionId: session.id })
}
return methodNotAllowed(res)
}
export const getPrice = (plan: 'pro' | 'team', currency: 'eur' | 'usd') => {
if (plan === 'team')
return currency === 'eur'
? process.env.STRIPE_PRICE_TEAM_EUR_ID
: process.env.STRIPE_PRICE_TEAM_USD_ID
return currency === 'eur'
? process.env.STRIPE_PRICE_EUR_ID
: process.env.STRIPE_PRICE_USD_ID
}
export default withSentry(handler)

View File

@@ -0,0 +1,49 @@
import { NextApiRequest, NextApiResponse } from 'next'
import {
badRequest,
forbidden,
methodNotAllowed,
notAuthenticated,
} from 'utils'
import Stripe from 'stripe'
import { withSentry } from '@sentry/nextjs'
import { getAuthenticatedUser } from 'services/api/utils'
import prisma from 'libs/prisma'
import { WorkspaceRole } from 'db'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res)
if (req.method === 'GET') {
const stripeId = req.query.stripeId as string | undefined
if (!stripeId) return badRequest(res)
if (!process.env.STRIPE_SECRET_KEY)
throw Error('STRIPE_SECRET_KEY var is missing')
const workspace = await prisma.workspace.findFirst({
where: {
stripeId,
members: { some: { userId: user.id, role: WorkspaceRole.ADMIN } },
},
})
if (!workspace?.stripeId) return forbidden(res)
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2022-08-01',
})
const invoices = await stripe.invoices.list({
customer: workspace.stripeId,
})
res.send({
invoices: invoices.data.map((i) => ({
id: i.number,
url: i.invoice_pdf,
amount: i.subtotal,
currency: i.currency,
date: i.status_transitions.paid_at,
})),
})
return
}
return methodNotAllowed(res)
}
export default withSentry(handler)

View File

@@ -0,0 +1,240 @@
import { NextApiRequest, NextApiResponse } from 'next'
import {
badRequest,
forbidden,
isDefined,
methodNotAllowed,
notAuthenticated,
} from 'utils'
import Stripe from 'stripe'
import { withSentry } from '@sentry/nextjs'
import { getAuthenticatedUser } from 'services/api/utils'
import prisma from 'libs/prisma'
import { Plan, WorkspaceRole } from 'db'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res)
if (req.method === 'GET')
return res.send(await getSubscriptionDetails(req, res)(user.id))
if (req.method === 'POST') {
const session = await createCheckoutSession(req)
return res.send({ sessionId: session.id })
}
if (req.method === 'PUT') {
await updateSubscription(req)
return res.send({ message: 'success' })
}
if (req.method === 'DELETE') {
await cancelSubscription(req, res)(user.id)
return res.send({ message: 'success' })
}
return methodNotAllowed(res)
}
const getSubscriptionDetails =
(req: NextApiRequest, res: NextApiResponse) => async (userId: string) => {
const stripeId = req.query.stripeId as string | undefined
if (!stripeId) return badRequest(res)
if (!process.env.STRIPE_SECRET_KEY)
throw Error('STRIPE_SECRET_KEY var is missing')
const workspace = await prisma.workspace.findFirst({
where: {
stripeId,
members: { some: { userId, role: WorkspaceRole.ADMIN } },
},
})
if (!workspace?.stripeId) return forbidden(res)
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2022-08-01',
})
const subscriptions = await stripe.subscriptions.list({
customer: workspace.stripeId,
limit: 1,
})
return {
additionalChatsIndex:
subscriptions.data[0].items.data.find(
(item) =>
item.price.id === process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID
)?.quantity ?? 0,
additionalStorageIndex:
subscriptions.data[0].items.data.find(
(item) =>
item.price.id === process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
)?.quantity ?? 0,
}
}
const createCheckoutSession = (req: NextApiRequest) => {
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 {
email,
currency,
plan,
workspaceId,
href,
additionalChats,
additionalStorage,
} = typeof req.body === 'string' ? JSON.parse(req.body) : req.body
return stripe.checkout.sessions.create({
success_url: `${href}?stripe=${plan}&success=true`,
cancel_url: `${href}?stripe=cancel`,
allow_promotion_codes: true,
customer_email: email,
mode: 'subscription',
metadata: { workspaceId, plan, additionalChats, additionalStorage },
currency,
automatic_tax: { enabled: true },
line_items: parseSubscriptionItems(
plan,
additionalChats,
additionalStorage
),
})
}
const updateSubscription = async (req: NextApiRequest) => {
const { customerId, plan, workspaceId, additionalChats, additionalStorage } =
(typeof req.body === 'string' ? JSON.parse(req.body) : req.body) as {
customerId: string
workspaceId: string
additionalChats: number
additionalStorage: number
plan: 'STARTER' | 'PRO'
}
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 { data } = await stripe.subscriptions.list({
customer: customerId,
})
const subscription = data[0]
const currentStarterPlanItemId = subscription.items.data.find(
(item) => item.price.id === process.env.STRIPE_STARTER_PRICE_ID
)?.id
const currentProPlanItemId = subscription.items.data.find(
(item) => item.price.id === process.env.STRIPE_PRO_PRICE_ID
)?.id
const currentAdditionalChatsItemId = subscription.items.data.find(
(item) => item.price.id === process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID
)?.id
const currentAdditionalStorageItemId = subscription.items.data.find(
(item) => item.price.id === process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
)?.id
const items = [
{
id: currentStarterPlanItemId ?? currentProPlanItemId,
price:
plan === Plan.STARTER
? process.env.STRIPE_STARTER_PRICE_ID
: process.env.STRIPE_PRO_PRICE_ID,
quantity: 1,
},
currentAdditionalChatsItemId
? {
id: currentAdditionalChatsItemId,
price: process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID,
quantity: additionalChats,
deleted: additionalChats === 0,
}
: undefined,
currentAdditionalStorageItemId
? {
id: currentAdditionalStorageItemId,
price: process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID,
quantity: additionalStorage,
deleted: additionalStorage === 0,
}
: undefined,
].filter(isDefined)
console.log(items)
await stripe.subscriptions.update(subscription.id, {
items,
})
await prisma.workspace.update({
where: { id: workspaceId },
data: {
plan,
additionalChatsIndex: additionalChats,
additionalStorageIndex: additionalStorage,
},
})
}
const cancelSubscription =
(req: NextApiRequest, res: NextApiResponse) => async (userId: string) => {
console.log(req.query.stripeId, userId)
const stripeId = req.query.stripeId as string | undefined
if (!stripeId) return badRequest(res)
if (!process.env.STRIPE_SECRET_KEY)
throw Error('STRIPE_SECRET_KEY var is missing')
const workspace = await prisma.workspace.findFirst({
where: {
stripeId,
members: { some: { userId, role: WorkspaceRole.ADMIN } },
},
})
if (!workspace?.stripeId) return forbidden(res)
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2022-08-01',
})
const existingSubscription = await stripe.subscriptions.list({
customer: workspace.stripeId,
})
console.log('yes')
await stripe.subscriptions.del(existingSubscription.data[0].id)
console.log('deleted')
await prisma.workspace.update({
where: { id: workspace.id },
data: {
plan: Plan.FREE,
additionalChatsIndex: 0,
additionalStorageIndex: 0,
},
})
}
const parseSubscriptionItems = (
plan: Plan,
additionalChats: number,
additionalStorage: number
) =>
[
{
price:
plan === Plan.STARTER
? process.env.STRIPE_STARTER_PRICE_ID
: process.env.STRIPE_PRO_PRICE_ID,
quantity: 1,
},
]
.concat(
additionalChats > 0
? [
{
price: process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID,
quantity: additionalChats,
},
]
: []
)
.concat(
additionalStorage > 0
? [
{
price: process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID,
quantity: additionalStorage,
},
]
: []
)
export default withSentry(handler)

View File

@@ -1,46 +0,0 @@
import { withSentry } from '@sentry/nextjs'
import { Plan } from 'db'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import Stripe from 'stripe'
import { badRequest, methodNotAllowed } from 'utils'
import { getPrice } from './checkout'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'POST') {
const { customerId, currency, plan, workspaceId } =
typeof req.body === 'string' ? JSON.parse(req.body) : req.body
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 subscriptions = await stripe.subscriptions.list({
customer: customerId,
})
const { id, items } = subscriptions.data[0]
const newPrice = getPrice(plan, currency)
const oldPrice = subscriptions.data[0].items.data[0].price.id
if (newPrice === oldPrice) return badRequest(res)
await stripe.subscriptions.update(id, {
cancel_at_period_end: false,
proration_behavior: 'create_prorations',
items: [
{
id: items.data[0].id,
price: getPrice(plan, currency),
},
],
})
await prisma.workspace.update({
where: { id: workspaceId },
data: {
plan: plan === 'team' ? Plan.TEAM : Plan.PRO,
},
})
return res.send({ message: 'success' })
}
methodNotAllowed(res)
}
export default withSentry(handler)

View File

@@ -4,7 +4,6 @@ import Stripe from 'stripe'
import Cors from 'micro-cors'
import { buffer } from 'micro'
import prisma from 'libs/prisma'
import { Plan } from 'db'
import { withSentry } from '@sentry/nextjs'
if (!process.env.STRIPE_SECRET_KEY || !process.env.STRIPE_WEBHOOK_SECRET)
@@ -40,30 +39,29 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session
const { metadata } = session
if (!metadata?.workspaceId || !metadata?.plan)
return res.status(500).send({ message: `customer_email not found` })
const { workspaceId, plan, additionalChats, additionalStorage } =
session.metadata as unknown as {
plan: 'STARTER' | 'PRO'
additionalChats: string
additionalStorage: string
workspaceId: string
}
if (!workspaceId || !plan || !additionalChats || !additionalStorage)
return res
.status(500)
.send({ message: `Couldn't retrieve valid metadata` })
await prisma.workspace.update({
where: { id: metadata.workspaceId },
where: { id: workspaceId },
data: {
plan: metadata.plan === 'team' ? Plan.TEAM : Plan.PRO,
plan: plan,
stripeId: session.customer as string,
additionalChatsIndex: parseInt(additionalChats),
additionalStorageIndex: parseInt(additionalStorage),
},
})
return res.status(200).send({ message: 'workspace upgraded in DB' })
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription
await prisma.workspace.update({
where: {
stripeId: subscription.customer as string,
},
data: {
plan: Plan.FREE,
},
})
return res.send({ message: 'workspace downgraded in DB' })
}
default: {
return res.status(304).send({ message: 'event not handled' })
}

View File

@@ -4,7 +4,7 @@ import { CollaborationType, WorkspaceRole } from 'db'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { canReadTypebot, canWriteTypebot } from 'services/api/dbRules'
import { sendEmailNotification } from 'services/api/emails'
import { sendEmailNotification } from 'utils'
import { getAuthenticatedUser } from 'services/api/utils'
import {
badRequest,
@@ -29,6 +29,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'POST') {
const typebot = await prisma.typebot.findFirst({
where: canWriteTypebot(typebotId, user),
include: { workspace: { select: { name: true } } },
})
if (!typebot || !typebot.workspaceId) return forbidden(res)
const { email, type } =
@@ -70,10 +71,13 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
await sendEmailNotification({
to: email,
subject: "You've been invited to collaborate 🤝",
content: invitationToCollaborate(
user.email ?? '',
`${process.env.NEXTAUTH_URL}/typebots?workspaceId=${typebot.workspaceId}`
),
html: invitationToCollaborate({
hostEmail: user.email ?? '',
url: `${process.env.NEXTAUTH_URL}/typebots?workspaceId=${typebot.workspaceId}`,
guestEmail: email.toLowerCase(),
typebotName: typebot.name,
workspaceName: typebot.workspace?.name ?? '',
}),
})
return res.send({
message: 'success',

View File

@@ -1,5 +1,5 @@
import { withSentry } from '@sentry/nextjs'
import { Workspace } from 'db'
import { Plan, Workspace } from 'db'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { getAuthenticatedUser } from 'services/api/utils'
@@ -22,7 +22,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
data: {
...data,
members: { create: [{ role: 'ADMIN', userId: user.id }] },
plan: process.env.ADMIN_EMAIL === user.email ? 'TEAM' : 'FREE',
plan:
process.env.ADMIN_EMAIL === user.email ? Plan.LIFETIME : Plan.FREE,
},
})
return res.status(200).json({

View File

@@ -1,9 +1,17 @@
import { withSentry } from '@sentry/nextjs'
import { WorkspaceInvitation, WorkspaceRole } from 'db'
import { workspaceMemberInvitationEmail } from 'assets/emails/workspaceMemberInvitation'
import { Workspace, WorkspaceInvitation, WorkspaceRole } from 'db'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { sendEmailNotification } from 'utils'
import { getAuthenticatedUser } from 'services/api/utils'
import { forbidden, methodNotAllowed, notAuthenticated } from 'utils'
import {
env,
forbidden,
methodNotAllowed,
notAuthenticated,
seatsLimit,
} from 'utils'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req)
@@ -20,6 +28,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
},
})
if (!workspace) return forbidden(res)
if (await checkIfSeatsLimitReached(workspace))
return res.status(400).send('Seats limit reached')
if (existingUser) {
await prisma.memberInWorkspace.create({
data: {
@@ -37,11 +48,28 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
workspaceId: data.workspaceId,
},
})
}
const invitation = await prisma.workspaceInvitation.create({ data })
return res.send({ invitation })
} else await prisma.workspaceInvitation.create({ data })
if (env('E2E_TEST') !== 'true')
await sendEmailNotification({
to: data.email,
subject: "You've been invited to collaborate 🤝",
html: workspaceMemberInvitationEmail({
workspaceName: workspace.name,
guestEmail: data.email,
url: `${process.env.NEXTAUTH_URL}/typebots?workspaceId=${workspace.id}`,
hostEmail: user.email ?? '',
}),
})
return res.send({ message: 'success' })
}
methodNotAllowed(res)
}
const checkIfSeatsLimitReached = async (workspace: Workspace) => {
const existingMembersCount = await prisma.memberInWorkspace.count({
where: { workspaceId: workspace.id },
})
return existingMembersCount >= seatsLimit[workspace.plan].totalIncluded
}
export default withSentry(handler)

View File

@@ -0,0 +1,54 @@
import { withSentry } from '@sentry/nextjs'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { getAuthenticatedUser } from 'services/api/utils'
import { methodNotAllowed, notAuthenticated } from 'utils'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res)
if (req.method === 'GET') {
const workspaceId = req.query.workspaceId as string
const now = new Date()
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
const lastDayOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0)
const totalChatsUsed = await prisma.result.count({
where: {
typebot: {
workspace: {
id: workspaceId,
members: { some: { userId: user.id } },
},
},
hasStarted: true,
createdAt: {
gte: firstDayOfMonth,
lte: lastDayOfMonth,
},
},
})
const {
_sum: { storageUsed: totalStorageUsed },
} = await prisma.answer.aggregate({
where: {
storageUsed: { gt: 0 },
result: {
typebot: {
workspace: {
id: workspaceId,
members: { some: { userId: user.id } },
},
},
},
},
_sum: { storageUsed: true },
})
return res.send({
totalChatsUsed,
totalStorageUsed,
})
}
methodNotAllowed(res)
}
export default withSentry(handler)