2
0
Files
bot/apps/builder/src/pages/api/stripe/webhook.ts
2024-03-15 16:32:29 +01:00

347 lines
11 KiB
TypeScript

import { NextApiRequest, NextApiResponse } from 'next'
import { methodNotAllowed } from '@typebot.io/lib/api'
import Stripe from 'stripe'
import Cors from 'micro-cors'
import { buffer } from 'micro'
import prisma from '@typebot.io/lib/prisma'
import { Plan, WorkspaceRole } from '@typebot.io/prisma'
import { RequestHandler } from 'next/dist/server/next'
import { Settings } from '@typebot.io/schemas'
import { env } from '@typebot.io/env'
import { prices } from '@typebot.io/billing/constants'
import { trackEvents } from '@typebot.io/telemetry/trackEvents'
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 cors = Cors({
allowMethods: ['POST', 'HEAD'],
})
const webhookSecret = env.STRIPE_WEBHOOK_SECRET as string
export const config = {
api: {
bodyParser: false,
},
}
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)
}
export default cors(webhookHandler as RequestHandler)