2
0

📄 Add Commercial License for ee folder (#1532)

This commit is contained in:
Baptiste Arnaud
2024-05-23 10:42:23 +02:00
committed by GitHub
parent 5680829906
commit 0eacbebbbe
246 changed files with 1472 additions and 1588 deletions

View File

@ -0,0 +1,104 @@
import { isAdminWriteWorkspaceForbidden } from '@typebot.io/db-rules/isAdminWriteWorkspaceForbidden'
import { env } from '@typebot.io/env'
import prisma from '@typebot.io/lib/prisma'
import { User } from '@typebot.io/prisma'
import Stripe from 'stripe'
import { createCheckoutSessionUrl } from '../helpers/createCheckoutSessionUrl'
import { TRPCError } from '@trpc/server'
type Props = {
workspaceId: string
user: Pick<User, 'email' | 'id'>
returnUrl: string
email: string
company: string
plan: 'STARTER' | 'PRO'
currency: 'usd' | 'eur'
vat?: {
type: string
value: string
}
}
export const createCheckoutSession = async ({
workspaceId,
user,
returnUrl,
email,
company,
plan,
currency,
vat,
}: Props) => {
if (!env.STRIPE_SECRET_KEY)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Stripe environment variables are missing',
})
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
},
select: {
stripeId: true,
members: {
select: {
userId: true,
role: true,
},
},
},
})
if (!workspace || isAdminWriteWorkspaceForbidden(workspace, user))
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Workspace not found',
})
if (workspace.stripeId)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Customer already exists, use updateSubscription endpoint.',
})
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: '2022-11-15',
})
await prisma.user.updateMany({
where: {
id: user.id,
},
data: {
company,
},
})
const customer = await stripe.customers.create({
email,
name: company,
metadata: { workspaceId },
tax_id_data: vat
? [vat as Stripe.CustomerCreateParams.TaxIdDatum]
: undefined,
})
const checkoutUrl = await createCheckoutSessionUrl(stripe)({
customerId: customer.id,
userId: user.id,
workspaceId,
currency,
plan,
returnUrl,
})
if (!checkoutUrl)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Stripe checkout session creation failed',
})
return {
checkoutUrl,
}
}

View File

@ -0,0 +1,158 @@
import { TRPCError } from '@trpc/server'
import { isAdminWriteWorkspaceForbidden } from '@typebot.io/db-rules/isAdminWriteWorkspaceForbidden'
import { env } from '@typebot.io/env'
import prisma from '@typebot.io/lib/prisma'
import { Plan, User } from '@typebot.io/prisma'
import Stripe from 'stripe'
type Props = {
workspaceId: string
user: Pick<User, 'email' | 'id'>
returnUrl: string
email: string
}
export const createCustomCheckoutSession = async ({
workspaceId,
user,
returnUrl,
email,
}: Props) => {
if (!env.STRIPE_SECRET_KEY)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Stripe environment variables are missing',
})
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
},
select: {
stripeId: true,
claimableCustomPlan: true,
name: true,
members: {
select: {
userId: true,
role: true,
},
},
},
})
if (
!workspace?.claimableCustomPlan ||
workspace.claimableCustomPlan.claimedAt ||
isAdminWriteWorkspaceForbidden(workspace, user)
)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Custom plan not found',
})
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: '2022-11-15',
})
const vat =
workspace.claimableCustomPlan.vatValue &&
workspace.claimableCustomPlan.vatType
? ({
type: workspace.claimableCustomPlan.vatType,
value: workspace.claimableCustomPlan.vatValue,
} as Stripe.CustomerCreateParams.TaxIdDatum)
: undefined
const customer = workspace.stripeId
? await stripe.customers.retrieve(workspace.stripeId)
: await stripe.customers.create({
email,
name: workspace.claimableCustomPlan.companyName ?? workspace.name,
metadata: { workspaceId },
tax_id_data: vat ? [vat] : undefined,
})
const session = await stripe.checkout.sessions.create({
success_url: `${returnUrl}?stripe=${Plan.CUSTOM}&success=true`,
cancel_url: `${returnUrl}?stripe=cancel`,
allow_promotion_codes: true,
customer: customer.id,
customer_update: {
address: 'auto',
name: 'never',
},
mode: 'subscription',
metadata: {
claimableCustomPlanId: workspace.claimableCustomPlan.id,
},
currency: workspace.claimableCustomPlan.currency,
billing_address_collection: 'required',
automatic_tax: { enabled: true },
line_items: [
{
price_data: {
currency: workspace.claimableCustomPlan.currency,
tax_behavior: 'exclusive',
recurring: {
interval: workspace.claimableCustomPlan.isYearly ? 'year' : 'month',
},
product_data: {
name: workspace.claimableCustomPlan.name,
description: workspace.claimableCustomPlan.description ?? undefined,
},
unit_amount: workspace.claimableCustomPlan.price * 100,
},
quantity: 1,
},
{
price_data: {
currency: workspace.claimableCustomPlan.currency,
tax_behavior: 'exclusive',
recurring: {
interval: workspace.claimableCustomPlan.isYearly ? 'year' : 'month',
},
product_data: {
name: 'Included chats per month',
},
unit_amount: 0,
},
quantity: workspace.claimableCustomPlan.chatsLimit,
},
{
price_data: {
currency: workspace.claimableCustomPlan.currency,
tax_behavior: 'exclusive',
recurring: {
interval: workspace.claimableCustomPlan.isYearly ? 'year' : 'month',
},
product_data: {
name: 'Included storage per month',
},
unit_amount: 0,
},
quantity: workspace.claimableCustomPlan.storageLimit,
},
{
price_data: {
currency: workspace.claimableCustomPlan.currency,
tax_behavior: 'exclusive',
recurring: {
interval: workspace.claimableCustomPlan.isYearly ? 'year' : 'month',
},
product_data: {
name: 'Included seats',
},
unit_amount: 0,
},
quantity: workspace.claimableCustomPlan.seatsLimit,
},
],
})
if (!session.url)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Stripe checkout session creation failed',
})
return {
checkoutUrl: session.url,
}
}

View File

@ -0,0 +1,47 @@
import { TRPCError } from '@trpc/server'
import { isAdminWriteWorkspaceForbidden } from '@typebot.io/db-rules/isAdminWriteWorkspaceForbidden'
import { env } from '@typebot.io/env'
import prisma from '@typebot.io/lib/prisma'
import { User } from '@typebot.io/prisma'
import Stripe from 'stripe'
type Props = {
workspaceId: string
user: Pick<User, 'email' | 'id'>
}
export const getBillingPortalUrl = async ({ workspaceId, user }: Props) => {
if (!env.STRIPE_SECRET_KEY)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'STRIPE_SECRET_KEY var is missing',
})
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
},
select: {
stripeId: true,
members: {
select: {
userId: true,
role: true,
},
},
},
})
if (!workspace?.stripeId || isAdminWriteWorkspaceForbidden(workspace, user))
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Workspace not found',
})
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: '2022-11-15',
})
const portalSession = await stripe.billingPortal.sessions.create({
customer: workspace.stripeId,
return_url: `${env.NEXTAUTH_URL}/typebots`,
})
return {
billingPortalUrl: portalSession.url,
}
}

View File

@ -0,0 +1,74 @@
import { TRPCError } from '@trpc/server'
import { isReadWorkspaceFobidden } from '@typebot.io/db-rules/isReadWorkspaceFobidden'
import { env } from '@typebot.io/env'
import prisma from '@typebot.io/lib/prisma'
import { User } from '@typebot.io/prisma'
import Stripe from 'stripe'
import { subscriptionSchema } from '@typebot.io/schemas/features/billing/subscription'
type Props = {
workspaceId: string
user: Pick<User, 'email' | 'id'>
}
export const getSubscription = async ({ workspaceId, user }: Props) => {
if (!env.STRIPE_SECRET_KEY)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Stripe environment variables are missing',
})
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
},
select: {
stripeId: true,
members: {
select: {
userId: true,
},
},
},
})
if (!workspace || isReadWorkspaceFobidden(workspace, user))
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Workspace not found',
})
if (!workspace?.stripeId)
return {
subscription: null,
}
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: '2022-11-15',
})
const subscriptions = await stripe.subscriptions.list({
customer: workspace.stripeId,
})
const currentSubscription = subscriptions.data
.filter((sub) => ['past_due', 'active'].includes(sub.status))
.sort((a, b) => a.created - b.created)
.shift()
if (!currentSubscription)
return {
subscription: null,
}
return {
subscription: {
currentBillingPeriod: subscriptionSchema.shape.currentBillingPeriod.parse(
{
start: new Date(currentSubscription.current_period_start),
end: new Date(currentSubscription.current_period_end),
}
),
status: subscriptionSchema.shape.status.parse(currentSubscription.status),
currency: currentSubscription.currency as 'usd' | 'eur',
cancelDate: currentSubscription.cancel_at
? new Date(currentSubscription.cancel_at * 1000)
: undefined,
},
}
}

View File

@ -0,0 +1,96 @@
import { TRPCError } from '@trpc/server'
import { env } from '@typebot.io/env'
import prisma from '@typebot.io/lib/prisma'
import { User } from '@typebot.io/prisma'
import Stripe from 'stripe'
import { isReadWorkspaceFobidden } from '@typebot.io/db-rules/isReadWorkspaceFobidden'
type Props = {
workspaceId: string
user: Pick<User, 'email' | 'id'>
}
export const getUsage = async ({ workspaceId, user }: Props) => {
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
},
select: {
stripeId: true,
plan: true,
members: {
select: {
userId: true,
},
},
typebots: {
select: { id: true },
},
},
})
if (!workspace || isReadWorkspaceFobidden(workspace, user))
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Workspace not found',
})
if (
!env.STRIPE_SECRET_KEY ||
!workspace.stripeId ||
(workspace.plan !== 'STARTER' && workspace.plan !== 'PRO')
) {
const now = new Date()
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
const totalChatsUsed = await prisma.result.count({
where: {
typebotId: { in: workspace.typebots.map((typebot) => typebot.id) },
hasStarted: true,
createdAt: {
gte: firstDayOfMonth,
},
},
})
const firstDayOfNextMonth = new Date(
firstDayOfMonth.getFullYear(),
firstDayOfMonth.getMonth() + 1,
1
)
return { totalChatsUsed, resetsAt: firstDayOfNextMonth }
}
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: '2022-11-15',
})
const subscriptions = await stripe.subscriptions.list({
customer: workspace.stripeId,
})
const currentSubscription = subscriptions.data
.filter((sub) => ['past_due', 'active'].includes(sub.status))
.sort((a, b) => a.created - b.created)
.shift()
if (!currentSubscription)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: `No subscription found on workspace: ${workspaceId}`,
})
const totalChatsUsed = await prisma.result.count({
where: {
typebotId: { in: workspace.typebots.map((typebot) => typebot.id) },
hasStarted: true,
createdAt: {
gte: new Date(currentSubscription.current_period_start * 1000),
},
},
})
return {
totalChatsUsed,
resetsAt: new Date(currentSubscription.current_period_end * 1000),
}
}

View File

@ -0,0 +1,59 @@
import { TRPCError } from '@trpc/server'
import { isAdminWriteWorkspaceForbidden } from '@typebot.io/db-rules/isAdminWriteWorkspaceForbidden'
import { env } from '@typebot.io/env'
import { isDefined } from '@typebot.io/lib'
import prisma from '@typebot.io/lib/prisma'
import { User } from '@typebot.io/prisma'
import { Stripe } from 'stripe'
type Props = {
workspaceId: string
user: Pick<User, 'email' | 'id'>
}
export const listInvoices = async ({ workspaceId, user }: Props) => {
if (!env.STRIPE_SECRET_KEY)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'STRIPE_SECRET_KEY var is missing',
})
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
},
select: {
stripeId: true,
members: {
select: {
userId: true,
role: true,
},
},
},
})
if (!workspace?.stripeId || isAdminWriteWorkspaceForbidden(workspace, user))
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Workspace not found',
})
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: '2022-11-15',
})
const invoices = await stripe.invoices.list({
customer: workspace.stripeId,
limit: 50,
})
return {
invoices: invoices.data
.filter(
(invoice) => isDefined(invoice.invoice_pdf) && isDefined(invoice.id)
)
.map((invoice) => ({
id: invoice.number as string,
url: invoice.invoice_pdf as string,
amount: invoice.subtotal,
currency: invoice.currency,
date: invoice.status_transitions.paid_at,
})),
}
}

View File

@ -0,0 +1,153 @@
import { TRPCError } from '@trpc/server'
import { env } from '@typebot.io/env'
import prisma from '@typebot.io/lib/prisma'
import { Plan } from '@typebot.io/prisma'
import Stripe from 'stripe'
import { trackEvents } from '@typebot.io/telemetry/trackEvents'
import { isAdminWriteWorkspaceForbidden } from '@typebot.io/db-rules/isAdminWriteWorkspaceForbidden'
import { User } from '@typebot.io/schemas'
import { createCheckoutSessionUrl } from '../helpers/createCheckoutSessionUrl'
type Props = {
workspaceId: string
user: Pick<User, 'email' | 'id'>
plan: 'STARTER' | 'PRO'
returnUrl: string
currency: 'usd' | 'eur'
}
export const updateSubscription = async ({
workspaceId,
user,
plan,
returnUrl,
currency,
}: Props) => {
if (!env.STRIPE_SECRET_KEY)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Stripe environment variables are missing',
})
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
},
select: {
isPastDue: true,
stripeId: true,
members: {
select: {
userId: true,
role: true,
},
},
},
})
if (workspace?.isPastDue)
throw new TRPCError({
code: 'BAD_REQUEST',
message:
'You have unpaid invoices. Please head over your billing portal to pay it.',
})
if (!workspace?.stripeId || isAdminWriteWorkspaceForbidden(workspace, user))
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Workspace not found',
})
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: '2022-11-15',
})
const { data } = await stripe.subscriptions.list({
customer: workspace.stripeId,
limit: 1,
status: 'active',
})
const subscription = data[0] as Stripe.Subscription | undefined
const currentPlanItemId = subscription?.items.data.find((item) =>
[env.STRIPE_STARTER_PRICE_ID, env.STRIPE_PRO_PRICE_ID].includes(
item.price.id
)
)?.id
const currentUsageItemId = subscription?.items.data.find(
(item) =>
item.price.id === env.STRIPE_STARTER_CHATS_PRICE_ID ||
item.price.id === env.STRIPE_PRO_CHATS_PRICE_ID
)?.id
const items = [
{
id: currentPlanItemId,
price:
plan === Plan.STARTER
? env.STRIPE_STARTER_PRICE_ID
: env.STRIPE_PRO_PRICE_ID,
quantity: 1,
},
{
id: currentUsageItemId,
price:
plan === Plan.STARTER
? env.STRIPE_STARTER_CHATS_PRICE_ID
: env.STRIPE_PRO_CHATS_PRICE_ID,
},
]
if (subscription) {
if (plan === 'STARTER') {
const totalChatsUsed = await prisma.result.count({
where: {
typebot: { workspaceId },
hasStarted: true,
createdAt: {
gte: new Date(subscription.current_period_start * 1000),
},
},
})
if (totalChatsUsed >= 4000) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
"You have collected more than 4000 chats during this billing cycle. You can't downgrade to the Starter.",
})
}
}
await stripe.subscriptions.update(subscription.id, {
items,
proration_behavior: 'always_invoice',
})
} else {
const checkoutUrl = await createCheckoutSessionUrl(stripe)({
customerId: workspace.stripeId,
userId: user.id,
workspaceId,
currency,
plan,
returnUrl,
})
return { checkoutUrl }
}
const updatedWorkspace = await prisma.workspace.update({
where: { id: workspaceId },
data: {
plan,
isQuarantined: false,
},
})
await trackEvents([
{
name: 'Subscription updated',
workspaceId,
userId: user.id,
data: {
plan,
},
},
])
return { workspace: updatedWorkspace }
}

View File

@ -0,0 +1,336 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { Stripe } from 'stripe'
import { buffer } from 'micro'
import { env } from '@typebot.io/env'
import { Plan, WorkspaceRole } from '@typebot.io/prisma'
import prisma from '@typebot.io/lib/prisma'
import { trackEvents } from '@typebot.io/telemetry/trackEvents'
import { prices } from '../constants'
import { Settings } from '@typebot.io/schemas'
import { methodNotAllowed } from '@typebot.io/lib/api/utils'
if (!env.STRIPE_SECRET_KEY || !env.STRIPE_WEBHOOK_SECRET)
throw new Error('STRIPE_SECRET_KEY or STRIPE_WEBHOOK_SECRET missing')
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: '2022-11-15',
})
const webhookSecret = env.STRIPE_WEBHOOK_SECRET as string
export const webhookHandler = async (
req: NextApiRequest,
res: NextApiResponse
) => {
if (req.method === 'POST') {
const buf = await buffer(req)
const sig = req.headers['stripe-signature']
if (!sig) return res.status(400).send(`stripe-signature is missing`)
try {
const event = stripe.webhooks.constructEvent(
buf.toString(),
sig.toString(),
webhookSecret
)
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session
const metadata = session.metadata as unknown as
| {
plan: 'STARTER' | 'PRO'
workspaceId: string
userId: string
}
| { claimableCustomPlanId: string; userId: string }
if ('plan' in metadata) {
const { workspaceId, plan } = metadata
if (!workspaceId || !plan)
return res
.status(500)
.send({ message: `Couldn't retrieve valid metadata` })
const workspace = await prisma.workspace.update({
where: { id: workspaceId },
data: {
plan,
stripeId: session.customer as string,
isQuarantined: false,
},
include: {
members: {
select: { userId: true },
where: {
role: WorkspaceRole.ADMIN,
},
},
},
})
await trackEvents(
workspace.members.map((m) => ({
name: 'Subscription updated',
workspaceId,
userId: m.userId,
data: {
plan,
},
}))
)
} else {
const { claimableCustomPlanId, userId } = 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.updateMany({
where: { id: workspaceId },
data: {
plan: Plan.CUSTOM,
stripeId: session.customer as string,
customChatsLimit: chatsLimit,
customStorageLimit: storageLimit,
customSeatsLimit: seatsLimit,
},
})
await trackEvents([
{
name: 'Subscription updated',
workspaceId,
userId,
data: {
plan: Plan.CUSTOM,
},
},
])
}
return res.status(200).send({ message: 'workspace upgraded in DB' })
}
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription
if (subscription.status !== 'past_due')
return res.send({ message: 'Not past_due, skipping.' })
const existingWorkspace = await prisma.workspace.findFirst({
where: {
stripeId: subscription.customer as string,
},
select: {
isPastDue: true,
id: true,
members: {
select: { userId: true, role: true },
where: { role: WorkspaceRole.ADMIN },
},
},
})
if (!existingWorkspace) throw new Error('Workspace not found')
if (existingWorkspace?.isPastDue)
return res.send({
message: 'Workspace already past due, skipping.',
})
await prisma.workspace.updateMany({
where: {
id: existingWorkspace.id,
},
data: {
isPastDue: true,
},
})
await trackEvents(
existingWorkspace.members.map((m) => ({
name: 'Workspace past due',
workspaceId: existingWorkspace.id,
userId: m.userId,
}))
)
return res.send({ message: 'Workspace set to past due.' })
}
case 'invoice.paid': {
const invoice = event.data.object as Stripe.Invoice
const workspace = await prisma.workspace.findFirst({
where: {
stripeId: invoice.customer as string,
},
select: {
isPastDue: true,
},
})
if (!workspace?.isPastDue)
return res.send({ message: 'Workspace not past_due, skipping.' })
const outstandingInvoices = await stripe.invoices.list({
customer: invoice.customer as string,
status: 'open',
})
const outstandingInvoicesWithAdditionalUsageCosts =
outstandingInvoices.data.filter(
(invoice) => invoice.amount_due > prices['PRO'] * 100
)
if (outstandingInvoicesWithAdditionalUsageCosts.length > 0)
return res.send({
message: 'Workspace has outstanding invoices, skipping.',
})
const updatedWorkspace = await prisma.workspace.update({
where: {
stripeId: invoice.customer as string,
},
data: {
isPastDue: false,
},
select: {
id: true,
members: {
select: { userId: true },
where: {
role: WorkspaceRole.ADMIN,
},
},
},
})
await trackEvents(
updatedWorkspace.members.map((m) => ({
name: 'Workspace past due status removed',
workspaceId: updatedWorkspace.id,
userId: m.userId,
}))
)
return res.send({ message: 'Workspace was regulated' })
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription
const { data } = await stripe.subscriptions.list({
customer: subscription.customer as string,
limit: 1,
status: 'active',
})
const existingSubscription = data[0] as
| Stripe.Subscription
| undefined
if (existingSubscription)
return res.send({
message:
'An active subscription still exists. Skipping downgrade.',
})
const outstandingInvoices = await stripe.invoices.list({
customer: subscription.customer as string,
status: 'open',
})
const outstandingInvoicesWithAdditionalUsageCosts =
outstandingInvoices.data.filter(
(invoice) => invoice.amount_due > prices['PRO'] * 100
)
const workspaceExist =
(await prisma.workspace.count({
where: {
stripeId: subscription.customer as string,
},
})) > 0
if (!workspaceExist)
return res.send({ message: 'Workspace not found, skipping...' })
const workspace = await prisma.workspace.update({
where: {
stripeId: subscription.customer as string,
},
data: {
plan: Plan.FREE,
customChatsLimit: null,
customStorageLimit: null,
customSeatsLimit: null,
isPastDue: outstandingInvoicesWithAdditionalUsageCosts.length > 0,
},
include: {
members: {
select: { userId: true },
where: {
role: WorkspaceRole.ADMIN,
},
},
},
})
await trackEvents(
workspace.members.map((m) => ({
name: 'Subscription updated',
workspaceId: workspace.id,
userId: m.userId,
data: {
plan: Plan.FREE,
},
}))
)
const typebots = await prisma.typebot.findMany({
where: {
workspaceId: workspace.id,
isArchived: { not: true },
},
include: { publishedTypebot: true },
})
for (const typebot of typebots) {
const settings = typebot.settings as Settings
if (settings.general?.isBrandingEnabled) continue
await prisma.typebot.updateMany({
where: { id: typebot.id },
data: {
settings: {
...settings,
general: {
...settings.general,
isBrandingEnabled: true,
},
whatsApp: settings.whatsApp
? {
...settings.whatsApp,
isEnabled: false,
}
: undefined,
},
},
})
const publishedTypebotSettings = typebot.publishedTypebot
?.settings as Settings | null
if (
!publishedTypebotSettings ||
publishedTypebotSettings?.general?.isBrandingEnabled
)
continue
await prisma.publicTypebot.updateMany({
where: { id: typebot.id },
data: {
settings: {
...publishedTypebotSettings,
general: {
...publishedTypebotSettings.general,
isBrandingEnabled: true,
},
},
},
})
}
return res.send({ message: 'workspace downgraded in DB' })
}
default: {
return res.status(304).send({ message: 'event not handled' })
}
}
} catch (err) {
console.error(err)
if (err instanceof Error) {
console.error(err)
return res.status(400).send(`Webhook Error: ${err.message}`)
}
return res.status(500).send(`Error occured: ${err}`)
}
}
return methodNotAllowed(res)
}

View File

@ -0,0 +1,167 @@
import { Plan } from '@typebot.io/prisma'
import type { Stripe } from 'stripe'
export const prices = {
[Plan.STARTER]: 39,
[Plan.PRO]: 89,
} as const
export const chatsLimits = {
[Plan.FREE]: 200,
[Plan.STARTER]: 2000,
[Plan.PRO]: 10000,
} as const
export const seatsLimits = {
[Plan.FREE]: 1,
[Plan.OFFERED]: 1,
[Plan.STARTER]: 2,
[Plan.PRO]: 5,
[Plan.LIFETIME]: 8,
} as const
export const starterChatTiers = [
{
up_to: 2000,
flat_amount: 0,
},
{
up_to: 2500,
flat_amount: 1000,
},
{
up_to: 3000,
flat_amount: 2000,
},
{
up_to: 3500,
flat_amount: 3000,
},
{
up_to: 4000,
flat_amount: 4000,
},
{
up_to: 'inf',
unit_amount: 2,
},
] satisfies Stripe.PriceCreateParams.Tier[]
export const proChatTiers = [
{
up_to: 10000,
flat_amount: 0,
},
{
up_to: 15000,
flat_amount: 5000,
},
{
up_to: 20000,
flat_amount: 9500,
},
{
up_to: 30000,
flat_amount: 18000,
},
{
up_to: 40000,
flat_amount: 26000,
},
{
up_to: 50000,
flat_amount: 33500,
},
{
up_to: 60000,
flat_amount: 40700,
},
{
up_to: 70000,
flat_amount: 47700,
},
{
up_to: 80000,
flat_amount: 54500,
},
{
up_to: 90000,
flat_amount: 61100,
},
{
up_to: 100000,
flat_amount: 67500,
},
{
up_to: 120000,
flat_amount: 79900,
},
{
up_to: 140000,
flat_amount: 91900,
},
{
up_to: 160000,
flat_amount: 103700,
},
{
up_to: 180000,
flat_amount: 115300,
},
{
up_to: 200000,
flat_amount: 126700,
},
{
up_to: 300000,
flat_amount: 181700,
},
{
up_to: 400000,
flat_amount: 234700,
},
{
up_to: 500000,
flat_amount: 285700,
},
{
up_to: 600000,
flat_amount: 335700,
},
{
up_to: 700000,
flat_amount: 384700,
},
{
up_to: 800000,
flat_amount: 432700,
},
{
up_to: 900000,
flat_amount: 479700,
},
{
up_to: 1000000,
flat_amount: 525700,
},
{
up_to: 1200000,
flat_amount: 617100,
},
{
up_to: 1400000,
flat_amount: 707900,
},
{
up_to: 1600000,
flat_amount: 797900,
},
{
up_to: 1800000,
flat_amount: 887300,
},
{
up_to: 'inf',
unit_amount_decimal: '0.442',
},
] satisfies Stripe.PriceCreateParams.Tier[]

View File

@ -0,0 +1,51 @@
import { env } from '@typebot.io/env'
import Stripe from 'stripe'
type Props = {
customerId: string
workspaceId: string
currency: 'usd' | 'eur'
plan: 'STARTER' | 'PRO'
returnUrl: string
userId: string
}
export const createCheckoutSessionUrl =
(stripe: Stripe) =>
async ({ customerId, workspaceId, currency, plan, returnUrl }: Props) => {
const session = await stripe.checkout.sessions.create({
success_url: `${returnUrl}?stripe=${plan}&success=true`,
cancel_url: `${returnUrl}?stripe=cancel`,
allow_promotion_codes: true,
customer: customerId,
customer_update: {
address: 'auto',
name: 'never',
},
mode: 'subscription',
metadata: {
workspaceId,
plan,
},
currency,
billing_address_collection: 'required',
automatic_tax: { enabled: true },
line_items: [
{
price:
plan === 'STARTER'
? env.STRIPE_STARTER_PRICE_ID
: env.STRIPE_PRO_PRICE_ID,
quantity: 1,
},
{
price:
plan === 'STARTER'
? env.STRIPE_STARTER_CHATS_PRICE_ID
: env.STRIPE_PRO_CHATS_PRICE_ID,
},
],
})
return session.url
}

View File

@ -0,0 +1,21 @@
import { guessIfUserIsEuropean } from './guessIfUserIsEuropean'
type FormatPriceParams = {
currency?: 'eur' | 'usd'
maxFractionDigits?: number
}
export const formatPrice = (
price: number,
{ currency, maxFractionDigits = 0 }: FormatPriceParams = {
maxFractionDigits: 0,
}
) => {
const isEuropean = guessIfUserIsEuropean()
const formatter = new Intl.NumberFormat(isEuropean ? 'fr-FR' : 'en-US', {
style: 'currency',
currency: currency?.toUpperCase() ?? (isEuropean ? 'EUR' : 'USD'),
maximumFractionDigits: maxFractionDigits,
})
return formatter.format(price)
}

View File

@ -0,0 +1,19 @@
import { Plan } from '@typebot.io/prisma'
import { chatsLimits } from '../constants'
import { Workspace } from '@typebot.io/schemas'
export const getChatsLimit = ({
plan,
customChatsLimit,
}: Pick<Workspace, 'plan'> & {
customChatsLimit?: Workspace['customChatsLimit']
}) => {
if (
plan === Plan.UNLIMITED ||
plan === Plan.LIFETIME ||
plan === Plan.OFFERED
)
return 'inf'
if (plan === Plan.CUSTOM) return customChatsLimit ?? 'inf'
return chatsLimits[plan]
}

View File

@ -0,0 +1,12 @@
import { Workspace } from '@typebot.io/schemas'
import { seatsLimits } from '../constants'
import { Plan } from '@typebot.io/prisma'
export const getSeatsLimit = ({
plan,
customSeatsLimit,
}: Pick<Workspace, 'plan' | 'customSeatsLimit'>) => {
if (plan === Plan.UNLIMITED) return 'inf'
if (plan === Plan.CUSTOM) return customSeatsLimit ? customSeatsLimit : 'inf'
return seatsLimits[plan]
}

View File

@ -0,0 +1,56 @@
const europeanUnionCountryCodes = [
'AT',
'BE',
'BG',
'CY',
'CZ',
'DE',
'DK',
'EE',
'ES',
'FI',
'FR',
'GR',
'HR',
'HU',
'IE',
'IT',
'LT',
'LU',
'LV',
'MT',
'NL',
'PL',
'PT',
'RO',
'SE',
'SI',
'SK',
]
const europeanUnionExclusiveLanguageCodes = [
'fr',
'de',
'it',
'el',
'pl',
'fi',
'nl',
'hr',
'cs',
'hu',
'ro',
'sl',
'sv',
'bg',
]
export const guessIfUserIsEuropean = () => {
if (typeof window === 'undefined') return false
return window.navigator.languages.some((language) => {
const [languageCode, countryCode] = language.split('-')
return countryCode
? europeanUnionCountryCodes.includes(countryCode)
: europeanUnionExclusiveLanguageCodes.includes(languageCode)
})
}

View File

@ -0,0 +1,28 @@
{
"name": "@typebot.io/billing",
"version": "1.0.0",
"description": "",
"main": "index.js",
"license": "UNLICENSED",
"private": true,
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "Baptiste Arnaud",
"dependencies": {
"@typebot.io/prisma": "workspace:*",
"@typebot.io/schemas": "workspace:*",
"stripe": "12.13.0",
"@typebot.io/lib": "workspace:*",
"@trpc/server": "10.40.0",
"@typebot.io/env": "workspace:*",
"@typebot.io/telemetry": "workspace:*",
"@typebot.io/db-rules": "workspace:*",
"next": "14.1.0",
"micro": "10.0.1"
},
"devDependencies": {
"@typebot.io/tsconfig": "workspace:*"
}
}

View File

@ -0,0 +1,555 @@
export const taxIdTypes = [
{
type: 'ae_trn',
code: 'AE TRN',
name: 'United Arab Emirates',
emoji: '🇦🇪',
placeholder: '123456789012345',
},
{
type: 'au_abn',
code: 'AU ABN',
name: 'Australia',
emoji: '🇦🇺',
placeholder: '123456789012',
},
{
type: 'au_arn',
code: 'AU ARN',
name: 'Australia',
emoji: '🇦🇺',
placeholder: '123456789012',
},
{
type: 'bg_uic',
code: 'BG UIC',
name: 'Bulgaria',
emoji: '🇧🇬',
placeholder: 'BG123456789',
},
{
type: 'br_cnpj',
code: 'BR CNPJ',
name: 'Brazil',
emoji: '🇧🇷',
placeholder: '12.345.678/0001-23',
},
{
type: 'br_cpf',
code: 'BR CPF',
name: 'Brazil',
emoji: '🇧🇷',
placeholder: '123.456.789-01',
},
{
type: 'ca_bn',
code: 'CA BN',
name: 'Canada',
emoji: '🇨🇦',
placeholder: '123456789',
},
{
type: 'ca_gst_hst',
code: 'CA GST/HST',
name: 'Canada',
emoji: '🇨🇦',
placeholder: '123456789',
},
{
type: 'ca_pst_bc',
code: 'CA PST-BC',
name: 'Canada',
emoji: '🇨🇦',
placeholder: '123456789',
},
{
type: 'ca_pst_mb',
code: 'CA PST-MB',
name: 'Canada',
emoji: '🇨🇦',
placeholder: '123456789',
},
{
type: 'ca_pst_sk',
code: 'CA PST-SK',
name: 'Canada',
emoji: '🇨🇦',
placeholder: '123456789',
},
{
type: 'ca_qst',
code: 'CA QST',
name: 'Canada',
emoji: '🇨🇦',
placeholder: '123456789',
},
{
type: 'ch_vat',
code: 'CH VAT',
name: 'Switzerland',
emoji: '🇨🇭',
placeholder: 'CHE-123.456.789 MWST',
},
{
type: 'cl_tin',
code: 'CL TIN',
name: 'Chile',
emoji: '🇨🇱',
placeholder: '12345678-9',
},
{
type: 'eg_tin',
code: 'EG TIN',
name: 'Egypt',
emoji: '🇪🇬',
placeholder: '123456789012',
},
{
type: 'es_cif',
code: 'ES CIF',
name: 'Spain',
emoji: '🇪🇸',
placeholder: 'A12345678',
},
{
type: 'eu_oss_vat',
code: 'EU OSS VAT',
name: 'European Union',
emoji: '🇪🇺',
placeholder: 'XX123456789',
},
{
type: 'eu_vat',
code: 'AT VAT',
name: 'Austria',
emoji: '🇦🇹',
placeholder: 'ATU12345678',
},
{
type: 'eu_vat',
code: 'DE VAT',
name: 'Germany',
emoji: '🇩🇪',
placeholder: 'DE123456789',
},
{
type: 'eu_vat',
code: 'BE VAT',
name: 'Belgium',
emoji: '🇧🇪',
placeholder: 'BE0123456789',
},
{
type: 'eu_vat',
code: 'BG VAT',
name: 'Bulgaria',
emoji: '🇧🇬',
placeholder: 'BG123456789',
},
{
type: 'eu_vat',
code: 'CY VAT',
name: 'Cyprus',
emoji: '🇨🇾',
placeholder: 'CY12345678L',
},
{
type: 'eu_vat',
code: 'DK VAT',
name: 'Denmark',
emoji: '🇩🇰',
placeholder: 'DK12345678',
},
{
type: 'eu_vat',
code: 'EE VAT',
name: 'Estonia',
emoji: '🇪🇪',
placeholder: 'EE123456789',
},
{
type: 'eu_vat',
code: 'GR VAT',
name: 'Greece',
emoji: '🇬🇷',
placeholder: 'EL123456789',
},
{
type: 'eu_vat',
code: 'ES VAT',
name: 'Spain',
emoji: '🇪🇸',
placeholder: 'ESX12345678',
},
{
type: 'eu_vat',
code: 'FI VAT',
name: 'Finland',
emoji: '🇫🇮',
placeholder: 'FI12345678',
},
{
type: 'eu_vat',
code: 'FR VAT',
name: 'France',
emoji: '🇫🇷',
placeholder: 'FRXX123456789',
},
{
type: 'eu_vat',
code: 'HR VAT',
name: 'Croatia',
emoji: '🇭🇷',
placeholder: 'HR12345678901',
},
{
type: 'eu_vat',
code: 'IE VAT',
name: 'Ireland',
emoji: '🇮🇪',
placeholder: 'IE1X12345X',
},
{
type: 'eu_vat',
code: 'IT VAT',
name: 'Italy',
emoji: '🇮🇹',
placeholder: 'IT12345678901',
},
{
type: 'eu_vat',
code: 'LT VAT',
name: 'Lithuania',
emoji: '🇱🇹',
placeholder: 'LT123456789',
},
{
type: 'eu_vat',
code: 'LU VAT',
name: 'Luxembourg',
emoji: '🇱🇺',
placeholder: 'LU12345678',
},
{
type: 'eu_vat',
code: 'LV VAT',
name: 'Latvia',
emoji: '🇱🇻',
placeholder: 'LV12345678901',
},
{
type: 'eu_vat',
code: 'MT VAT',
name: 'Malta',
emoji: '🇲🇹',
placeholder: 'MT12345678',
},
{
type: 'eu_vat',
code: 'NL VAT',
name: 'Netherlands',
emoji: '🇳🇱',
placeholder: 'NL123456789B01',
},
{
type: 'eu_vat',
code: 'PL VAT',
name: 'Poland',
emoji: '🇵🇱',
placeholder: 'PL1234567890',
},
{
type: 'eu_vat',
code: 'PT VAT',
name: 'Portugal',
emoji: '🇵🇹',
placeholder: 'PT123456789',
},
{
type: 'eu_vat',
code: 'RO VAT',
name: 'Romania',
emoji: '🇷🇴',
placeholder: 'RO1234567891',
},
{
type: 'eu_vat',
code: 'SE VAT',
name: 'Sweden',
emoji: '🇸🇪',
placeholder: 'SE123456789101',
},
{
type: 'eu_vat',
code: 'SI VAT',
name: 'Slovenia',
emoji: '🇸🇮',
placeholder: 'SI12345678',
},
{
type: 'eu_vat',
code: 'SK VAT',
name: 'Slovakia',
emoji: '🇸🇰',
placeholder: 'SK1234567890',
},
{
type: 'eu_vat',
code: 'XI VAT',
name: 'Northern Ireland',
emoji: '🇮🇪',
placeholder: 'XI123456789',
},
{
type: 'eu_vat',
code: 'GE VAT',
name: 'Georgia',
emoji: '🇬🇪',
placeholder: 'GE123456789',
},
{
type: 'eu_vat',
code: 'CZ VAT',
name: 'Czech Republic',
emoji: '🇨🇿',
placeholder: 'CZ12345678',
},
{
type: 'gb_vat',
code: 'GB VAT',
name: 'United Kingdom',
emoji: '🇬🇧',
placeholder: 'GB123456789',
},
{
type: 'ge_vat',
code: 'GE VAT',
name: 'Georgia',
emoji: '🇬🇪',
placeholder: 'GE123456789',
},
{
type: 'hk_br',
code: 'HK BR',
name: 'Hong Kong',
emoji: '🇭🇰',
placeholder: '12345678',
},
{
type: 'hu_tin',
code: 'HU TIN',
name: 'Hungary',
emoji: '🇭🇺',
placeholder: '13804079-2-13',
},
{
type: 'hu_vat',
code: 'HU VAT',
name: 'Hungary',
emoji: '🇭🇺',
placeholder: 'HU12345678',
},
{
type: 'id_npwp',
code: 'ID NPWP',
name: 'Indonesia',
emoji: '🇮🇩',
placeholder: '123.456.7-8910.000',
},
{
type: 'il_vat',
code: 'IL VAT',
name: 'Israel',
emoji: '🇮🇱',
placeholder: '123456789',
},
{
type: 'in_gst',
code: 'IN GST',
name: 'India',
emoji: '🇮🇳',
placeholder: '12ABCDE1234F1Z5',
},
{
type: 'is_vat',
code: 'IS VAT',
name: 'Iceland',
emoji: '🇮🇸',
placeholder: '123456-7890',
},
{
type: 'jp_cn',
code: 'JP CN',
name: 'Japan',
emoji: '🇯🇵',
placeholder: '123456789012',
},
{
type: 'jp_rn',
code: 'JP RN',
name: 'Japan',
emoji: '🇯🇵',
placeholder: '123456789012',
},
{
type: 'jp_trn',
code: 'JP TRN',
name: 'Japan',
emoji: '🇯🇵',
placeholder: '123456789012',
},
{
type: 'ke_pin',
code: 'KE PIN',
name: 'Kenya',
emoji: '🇰🇪',
placeholder: '12345678A',
},
{
type: 'kr_brn',
code: 'KR BRN',
name: 'South Korea',
emoji: '🇰🇷',
placeholder: '1234567890',
},
{
type: 'li_uid',
code: 'LI UID',
name: 'Liechtenstein',
emoji: '🇱🇮',
placeholder: 'CHE-123.456.789',
},
{
type: 'mx_rfc',
code: 'MX RFC',
name: 'Mexico',
emoji: '🇲🇽',
placeholder: 'XAXX010101000',
},
{
type: 'my_frp',
code: 'MY FRP',
name: 'Malaysia',
emoji: '🇲🇾',
placeholder: '123456789012',
},
{
type: 'my_itn',
code: 'MY ITN',
name: 'Malaysia',
emoji: '🇲🇾',
placeholder: '123456789012',
},
{
type: 'my_sst',
code: 'MY SST',
name: 'Malaysia',
emoji: '🇲🇾',
placeholder: '123456789012',
},
{
type: 'no_vat',
code: 'NO VAT',
name: 'Norway',
emoji: '🇳🇴',
placeholder: '123456789',
},
{
type: 'nz_gst',
code: 'NZ GST',
name: 'New Zealand',
emoji: '🇳🇿',
placeholder: '123456789',
},
{
type: 'ph_tin',
code: 'PH TIN',
name: 'Philippines',
emoji: '🇵🇭',
placeholder: '123-456-789-012',
},
{
type: 'ru_inn',
code: 'RU INN',
name: 'Russia',
emoji: '🇷🇺',
placeholder: '123456789012',
},
{
type: 'ru_kpp',
code: 'RU KPP',
name: 'Russia',
emoji: '🇷🇺',
placeholder: '123456789',
},
{
type: 'sa_vat',
code: 'SA VAT',
name: 'Saudi Arabia',
emoji: '🇸🇦',
placeholder: '123456789012',
},
{
type: 'sg_gst',
code: 'SG GST',
name: 'Singapore',
emoji: '🇸🇬',
placeholder: '123456789',
},
{
type: 'sg_uen',
code: 'SG UEN',
name: 'Singapore',
emoji: '🇸🇬',
placeholder: '123456789',
},
{
type: 'si_tin',
code: 'SI TIN',
name: 'Slovenia',
emoji: '🇸🇮',
placeholder: '12345678',
},
{
type: 'th_vat',
code: 'TH VAT',
name: 'Thailand',
emoji: '🇹🇭',
placeholder: '1234567890123',
},
{
type: 'tr_tin',
code: 'TR TIN',
name: 'Turkey',
emoji: '🇹🇷',
placeholder: '1234567890',
},
{
type: 'tw_vat',
code: 'TW VAT',
name: 'Taiwan',
emoji: '🇹🇼',
placeholder: '12345678',
},
{
type: 'ua_vat',
code: 'UA VAT',
name: 'Ukraine',
emoji: '🇺🇦',
placeholder: '12345678',
},
{
type: 'us_ein',
code: 'US EIN',
name: 'United States',
emoji: '🇺🇸',
placeholder: '12-3456789',
},
{
type: 'za_vat',
code: 'ZA VAT',
name: 'South Africa',
emoji: '🇿🇦',
placeholder: '1234567890',
},
]

View File

@ -0,0 +1,8 @@
{
"extends": "@typebot.io/tsconfig/base.json",
"include": ["**/*.ts"],
"exclude": ["node_modules"],
"compilerOptions": {
"lib": ["ES2021", "DOM"]
}
}