🛂 (billing) Add isPastDue field in workspace (#1046)

Closes #1039

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
  - Workspaces now include additional status indicator: `isPastDue`.
- New pages for handling workspaces that are past due. Meaning, an
invoice is unpaid.

- **Bug Fixes**
- Fixed issues with workspace status checks and redirections for
suspended workspaces.

- **Refactor**
- Refactored workspace-related API functions to accommodate new status
fields.
- Improved permission checks for reading and writing typebots based on
workspace status.

- **Chores**
  - Database schema updated to include `isPastDue` field for workspaces.
- Implemented new webhook event handling for subscription and invoice
updates.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Baptiste Arnaud
2023-11-23 08:16:23 +01:00
committed by GitHub
parent 94886ca58e
commit ca79934ef5
27 changed files with 450 additions and 97 deletions

View File

@@ -66,7 +66,7 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
},
include: {
members: {
select: { user: { select: { id: true } } },
select: { userId: true },
where: {
role: WorkspaceRole.ADMIN,
},
@@ -74,19 +74,16 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
},
})
for (const user of workspace.members.map((member) => member.user)) {
if (!user?.id) continue
await sendTelemetryEvents([
{
name: 'Subscription updated',
workspaceId,
userId: user.id,
data: {
plan,
},
await sendTelemetryEvents(
workspace.members.map((m) => ({
name: 'Subscription updated',
workspaceId,
userId: m.userId,
data: {
plan,
},
])
}
}))
)
} else {
const { claimableCustomPlanId, userId } = metadata
if (!claimableCustomPlanId)
@@ -124,6 +121,80 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
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 workspace = await prisma.workspace.update({
where: {
stripeId: subscription.customer as string,
},
data: {
isPastDue: true,
},
select: {
id: true,
members: {
select: { userId: true, role: true },
where: { role: WorkspaceRole.ADMIN },
},
},
})
await sendTelemetryEvents(
workspace.members.map((m) => ({
name: 'Workspace past due',
workspaceId: workspace.id,
userId: m.userId,
}))
)
return res.send({ message: 'Workspace set to past due.' })
}
case 'invoices.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',
})
if (outstandingInvoices.data.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 sendTelemetryEvents(
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({
@@ -151,7 +222,7 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
},
include: {
members: {
select: { user: { select: { id: true } } },
select: { userId: true },
where: {
role: WorkspaceRole.ADMIN,
},
@@ -159,19 +230,16 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
},
})
for (const user of workspace.members.map((member) => member.user)) {
if (!user?.id) continue
await sendTelemetryEvents([
{
name: 'Subscription updated',
workspaceId: workspace.id,
userId: user.id,
data: {
plan: Plan.FREE,
},
await sendTelemetryEvents(
workspace.members.map((m) => ({
name: 'Subscription updated',
workspaceId: workspace.id,
userId: m.userId,
data: {
plan: Plan.FREE,
},
])
}
}))
)
const typebots = await prisma.typebot.findMany({
where: {

View File

@@ -0,0 +1,36 @@
import { AlertIcon } from '@/components/icons'
import { BillingPortalButton } from '@/features/billing/components/BillingPortalButton'
import { DashboardHeader } from '@/features/dashboard/components/DashboardHeader'
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
import { Heading, VStack, Text } from '@chakra-ui/react'
import { useRouter } from 'next/router'
import { useEffect } from 'react'
export default function Page() {
const { replace } = useRouter()
const { workspace } = useWorkspace()
useEffect(() => {
if (!workspace || workspace.isPastDue) return
replace('/typebots')
}, [replace, workspace])
return (
<>
<DashboardHeader />
<VStack
w="full"
h="calc(100vh - 64px)"
justifyContent="center"
spacing={4}
>
<AlertIcon fontSize="4xl" />
<Heading fontSize="2xl">Your workspace has unpaid invoice(s).</Heading>
<Text>Head over to the billing portal to pay it.</Text>
{workspace?.id && (
<BillingPortalButton workspaceId={workspace?.id} colorScheme="blue" />
)}
</VStack>
</>
)
}

View File

@@ -1,13 +1,28 @@
import { TextLink } from '@/components/TextLink'
import { DashboardHeader } from '@/features/dashboard/components/DashboardHeader'
import { WorkspaceProvider } from '@/features/workspace/WorkspaceProvider'
import { Heading, Link, Text, VStack } from '@chakra-ui/react'
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
import { Heading, Text, VStack } from '@chakra-ui/react'
import { useRouter } from 'next/router'
import { useEffect } from 'react'
export default function Page() {
const { replace } = useRouter()
const { workspace } = useWorkspace()
useEffect(() => {
if (!workspace || workspace.isSuspended) return
replace('/typebots')
}, [replace, workspace])
return (
<WorkspaceProvider>
<>
<DashboardHeader />
<VStack w="full" h="calc(100vh - 64px)" justifyContent="center">
<VStack
w="full"
h="calc(100vh - 64px)"
justifyContent="center"
spacing={4}
>
<Heading>Your workspace has been suspended.</Heading>
<Text>
We detected that one of your typebots does not comply with our{' '}
@@ -18,14 +33,7 @@ export default function Page() {
terms of service
</TextLink>
</Text>
<Text>
If you think it&apos;s a mistake, feel free to{' '}
<Link href="mailto:support@typebot.io" textDecor="underline">
reach out
</Link>
.
</Text>
</VStack>
</WorkspaceProvider>
</>
)
}