2
0

🚸 (billing) Make sure customer is not created before launching checkout page

This commit is contained in:
Baptiste Arnaud
2023-08-22 10:29:00 +02:00
parent c08e0cdb0a
commit 53dd7ba499
13 changed files with 108 additions and 43 deletions

View File

@@ -1,10 +1,11 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { Plan, WorkspaceRole } from '@typebot.io/prisma'
import { Plan } from '@typebot.io/prisma'
import Stripe from 'stripe'
import { z } from 'zod'
import { parseSubscriptionItems } from '../helpers/parseSubscriptionItems'
import { isAdminWriteWorkspaceForbidden } from '@/features/workspace/helpers/isAdminWriteWorkspaceForbidden'
export const createCheckoutSession = authenticatedProcedure
.meta({
@@ -64,14 +65,30 @@ export const createCheckoutSession = authenticatedProcedure
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
members: { some: { userId: user.id, role: WorkspaceRole.ADMIN } },
},
select: {
stripeId: true,
members: {
select: {
userId: true,
role: true,
},
},
},
})
if (!workspace)
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(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2022-11-15',
})

View File

@@ -1,9 +1,10 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { Plan, WorkspaceRole } from '@typebot.io/prisma'
import { Plan } from '@typebot.io/prisma'
import Stripe from 'stripe'
import { z } from 'zod'
import { isAdminWriteWorkspaceForbidden } from '@/features/workspace/helpers/isAdminWriteWorkspaceForbidden'
export const createCustomCheckoutSession = authenticatedProcedure
.meta({
@@ -38,15 +39,23 @@ export const createCustomCheckoutSession = authenticatedProcedure
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
members: { some: { userId: user.id, role: WorkspaceRole.ADMIN } },
},
include: {
select: {
stripeId: true,
claimableCustomPlan: true,
name: true,
members: {
select: {
userId: true,
role: true,
},
},
},
})
if (
!workspace?.claimableCustomPlan ||
workspace.claimableCustomPlan.claimedAt
workspace.claimableCustomPlan.claimedAt ||
isAdminWriteWorkspaceForbidden(workspace, user)
)
throw new TRPCError({
code: 'NOT_FOUND',

View File

@@ -1,9 +1,9 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { WorkspaceRole } from '@typebot.io/prisma'
import Stripe from 'stripe'
import { z } from 'zod'
import { isAdminWriteWorkspaceForbidden } from '@/features/workspace/helpers/isAdminWriteWorkspaceForbidden'
export const getBillingPortalUrl = authenticatedProcedure
.meta({
@@ -34,13 +34,18 @@ export const getBillingPortalUrl = authenticatedProcedure
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
members: { some: { userId: user.id, role: WorkspaceRole.ADMIN } },
},
select: {
stripeId: true,
members: {
select: {
userId: true,
role: true,
},
},
},
})
if (!workspace?.stripeId)
if (!workspace?.stripeId || isAdminWriteWorkspaceForbidden(workspace, user))
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Workspace not found',

View File

@@ -1,11 +1,11 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { WorkspaceRole } from '@typebot.io/prisma'
import Stripe from 'stripe'
import { z } from 'zod'
import { subscriptionSchema } from '@typebot.io/schemas/features/billing/subscription'
import { priceIds } from '@typebot.io/lib/pricing'
import { isReadWorkspaceFobidden } from '@/features/workspace/helpers/isReadWorkspaceFobidden'
export const getSubscription = authenticatedProcedure
.meta({
@@ -36,8 +36,20 @@ export const getSubscription = authenticatedProcedure
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
members: { some: { userId: user.id, role: WorkspaceRole.ADMIN } },
},
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 {

View File

@@ -1,11 +1,11 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { WorkspaceRole } from '@typebot.io/prisma'
import Stripe from 'stripe'
import { isDefined } from '@typebot.io/lib'
import { z } from 'zod'
import { invoiceSchema } from '@typebot.io/schemas/features/billing/invoice'
import { isAdminWriteWorkspaceForbidden } from '@/features/workspace/helpers/isAdminWriteWorkspaceForbidden'
export const listInvoices = authenticatedProcedure
.meta({
@@ -36,10 +36,18 @@ export const listInvoices = authenticatedProcedure
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
members: { some: { userId: user.id, role: WorkspaceRole.ADMIN } },
},
select: {
stripeId: true,
members: {
select: {
userId: true,
role: true,
},
},
},
})
if (!workspace?.stripeId)
if (!workspace?.stripeId || isAdminWriteWorkspaceForbidden(workspace, user))
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Workspace not found',

View File

@@ -2,7 +2,7 @@ import { sendTelemetryEvents } from '@typebot.io/lib/telemetry/sendTelemetryEven
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { Plan, WorkspaceRole } from '@typebot.io/prisma'
import { Plan } from '@typebot.io/prisma'
import { workspaceSchema } from '@typebot.io/schemas'
import Stripe from 'stripe'
import { isDefined } from '@typebot.io/lib'
@@ -14,6 +14,7 @@ import {
} from '@typebot.io/lib/pricing'
import { chatPriceIds, storagePriceIds } from './getSubscription'
import { createCheckoutSessionUrl } from './createCheckoutSession'
import { isAdminWriteWorkspaceForbidden } from '@/features/workspace/helpers/isAdminWriteWorkspaceForbidden'
export const updateSubscription = authenticatedProcedure
.meta({
@@ -63,10 +64,21 @@ export const updateSubscription = authenticatedProcedure
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
members: { some: { userId: user.id, role: WorkspaceRole.ADMIN } },
},
select: {
stripeId: true,
members: {
select: {
userId: true,
role: true,
},
},
},
})
if (!workspace?.stripeId)
if (
!workspace?.stripeId ||
isAdminWriteWorkspaceForbidden(workspace, user)
)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Workspace not found',

View File

@@ -197,7 +197,9 @@ test('plan changes should work', async ({ page }) => {
page.waitForNavigation(),
page.click('text="Billing portal"'),
])
await expect(page.getByText('$247.00 per month')).toBeVisible()
await expect(page.getByText('$247.00 per month')).toBeVisible({
timeout: 10000,
})
await expect(page.getByText('(×25000)')).toBeVisible()
await expect(page.getByText('(×15)')).toBeVisible()
await expect(page.locator('text="Add payment method"')).toBeVisible()

View File

@@ -8,7 +8,7 @@ import { UsageProgressBars } from './UsageProgressBars'
import { CurrentSubscriptionSummary } from './CurrentSubscriptionSummary'
export const BillingSettingsLayout = () => {
const { workspace, refreshWorkspace } = useWorkspace()
const { workspace } = useWorkspace()
if (!workspace) return null
return (
@@ -20,10 +20,7 @@ export const BillingSettingsLayout = () => {
workspace.plan !== Plan.LIFETIME &&
workspace.plan !== Plan.UNLIMITED &&
workspace.plan !== Plan.OFFERED && (
<ChangePlanForm
workspace={workspace}
onUpgradeSuccess={refreshWorkspace}
/>
<ChangePlanForm workspace={workspace} />
)}
</Stack>

View File

@@ -16,10 +16,9 @@ import { StripeClimateLogo } from './StripeClimateLogo'
type Props = {
workspace: Workspace
onUpgradeSuccess: () => void
}
export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
export const ChangePlanForm = ({ workspace }: Props) => {
const scopedT = useScopedI18n('billing')
const { user } = useUser()
@@ -28,7 +27,9 @@ export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
useState<PreCheckoutModalProps['selectedSubscription']>()
const [isYearly, setIsYearly] = useState(true)
const { data } = trpc.billing.getSubscription.useQuery(
const trpcContext = trpc.useContext()
const { data, refetch } = trpc.billing.getSubscription.useQuery(
{
workspaceId: workspace.id,
},
@@ -52,7 +53,8 @@ export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
window.location.href = checkoutUrl
return
}
onUpgradeSuccess()
refetch()
trpcContext.workspace.getWorkspace.invalidate()
showToast({
status: 'success',
description: scopedT('updateSuccessToast.description', {

View File

@@ -25,7 +25,7 @@ export const ChangePlanModal = ({
type,
}: ChangePlanModalProps) => {
const t = useI18n()
const { workspace, refreshWorkspace } = useWorkspace()
const { workspace } = useWorkspace()
return (
<Modal isOpen={isOpen} onClose={onClose} size="2xl">
<ModalOverlay />
@@ -36,12 +36,7 @@ export const ChangePlanModal = ({
{t('billing.upgradeLimitLabel', { type: type })}
</AlertInfo>
)}
{workspace && (
<ChangePlanForm
workspace={workspace}
onUpgradeSuccess={refreshWorkspace}
/>
)}
{workspace && <ChangePlanForm workspace={workspace} />}
</ModalBody>
<ModalFooter>

View File

@@ -25,7 +25,6 @@ const workspaceContext = createContext<{
createWorkspace: (name?: string) => Promise<void>
updateWorkspace: (updates: { icon?: string; name?: string }) => void
deleteCurrentWorkspace: () => Promise<void>
refreshWorkspace: () => void
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
}>({})
@@ -166,11 +165,6 @@ export const WorkspaceProvider = ({
await deleteWorkspaceMutation.mutateAsync({ workspaceId })
}
const refreshWorkspace = () => {
trpcContext.workspace.getWorkspace.invalidate()
trpcContext.billing.getSubscription.invalidate()
}
return (
<workspaceContext.Provider
value={{
@@ -181,7 +175,6 @@ export const WorkspaceProvider = ({
createWorkspace,
updateWorkspace,
deleteCurrentWorkspace,
refreshWorkspace,
}}
>
{children}

View File

@@ -2,7 +2,7 @@ import { MemberInWorkspace, User } from '@typebot.io/prisma'
export const isAdminWriteWorkspaceForbidden = (
workspace: {
members: MemberInWorkspace[]
members: Pick<MemberInWorkspace, 'role' | 'userId'>[]
},
user: Pick<User, 'email' | 'id'>
) => {

View File

@@ -133,6 +133,19 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
}
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 workspace = await prisma.workspace.update({
where: {
stripeId: subscription.customer as string,