From 53dd7ba499400aca415e1c4e5707788eb3505ad5 Mon Sep 17 00:00:00 2001 From: Baptiste Arnaud Date: Tue, 22 Aug 2023 10:29:00 +0200 Subject: [PATCH] :children_crossing: (billing) Make sure customer is not created before launching checkout page --- .../billing/api/createCheckoutSession.ts | 23 ++++++++++++++++--- .../api/createCustomCheckoutSession.ts | 17 ++++++++++---- .../billing/api/getBillingPortalUrl.ts | 11 ++++++--- .../features/billing/api/getSubscription.ts | 16 +++++++++++-- .../src/features/billing/api/listInvoices.ts | 14 ++++++++--- .../billing/api/updateSubscription.ts | 18 ++++++++++++--- .../src/features/billing/billing.spec.ts | 4 +++- .../components/BillingSettingsLayout.tsx | 7 ++---- .../billing/components/ChangePlanForm.tsx | 10 ++++---- .../billing/components/ChangePlanModal.tsx | 9 ++------ .../features/workspace/WorkspaceProvider.tsx | 7 ------ .../helpers/isAdminWriteWorkspaceForbidden.ts | 2 +- apps/builder/src/pages/api/stripe/webhook.ts | 13 +++++++++++ 13 files changed, 108 insertions(+), 43 deletions(-) diff --git a/apps/builder/src/features/billing/api/createCheckoutSession.ts b/apps/builder/src/features/billing/api/createCheckoutSession.ts index c06d83bb7..ce5a21eb3 100644 --- a/apps/builder/src/features/billing/api/createCheckoutSession.ts +++ b/apps/builder/src/features/billing/api/createCheckoutSession.ts @@ -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', }) diff --git a/apps/builder/src/features/billing/api/createCustomCheckoutSession.ts b/apps/builder/src/features/billing/api/createCustomCheckoutSession.ts index 0e45626b8..89d042b6f 100644 --- a/apps/builder/src/features/billing/api/createCustomCheckoutSession.ts +++ b/apps/builder/src/features/billing/api/createCustomCheckoutSession.ts @@ -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', diff --git a/apps/builder/src/features/billing/api/getBillingPortalUrl.ts b/apps/builder/src/features/billing/api/getBillingPortalUrl.ts index bcdef0941..16f08ef66 100644 --- a/apps/builder/src/features/billing/api/getBillingPortalUrl.ts +++ b/apps/builder/src/features/billing/api/getBillingPortalUrl.ts @@ -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', diff --git a/apps/builder/src/features/billing/api/getSubscription.ts b/apps/builder/src/features/billing/api/getSubscription.ts index ed27ef7db..0760937bc 100644 --- a/apps/builder/src/features/billing/api/getSubscription.ts +++ b/apps/builder/src/features/billing/api/getSubscription.ts @@ -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,9 +36,21 @@ 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 { subscription: null, diff --git a/apps/builder/src/features/billing/api/listInvoices.ts b/apps/builder/src/features/billing/api/listInvoices.ts index 8f3b4830e..33ec979c3 100644 --- a/apps/builder/src/features/billing/api/listInvoices.ts +++ b/apps/builder/src/features/billing/api/listInvoices.ts @@ -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', diff --git a/apps/builder/src/features/billing/api/updateSubscription.ts b/apps/builder/src/features/billing/api/updateSubscription.ts index c4c06151a..86f97c7eb 100644 --- a/apps/builder/src/features/billing/api/updateSubscription.ts +++ b/apps/builder/src/features/billing/api/updateSubscription.ts @@ -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', diff --git a/apps/builder/src/features/billing/billing.spec.ts b/apps/builder/src/features/billing/billing.spec.ts index f5b5d1424..7dbd5a6f4 100644 --- a/apps/builder/src/features/billing/billing.spec.ts +++ b/apps/builder/src/features/billing/billing.spec.ts @@ -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() diff --git a/apps/builder/src/features/billing/components/BillingSettingsLayout.tsx b/apps/builder/src/features/billing/components/BillingSettingsLayout.tsx index a8d07fc35..c6ada09aa 100644 --- a/apps/builder/src/features/billing/components/BillingSettingsLayout.tsx +++ b/apps/builder/src/features/billing/components/BillingSettingsLayout.tsx @@ -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 && ( - + )} diff --git a/apps/builder/src/features/billing/components/ChangePlanForm.tsx b/apps/builder/src/features/billing/components/ChangePlanForm.tsx index 93db69a2d..036904d7f 100644 --- a/apps/builder/src/features/billing/components/ChangePlanForm.tsx +++ b/apps/builder/src/features/billing/components/ChangePlanForm.tsx @@ -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() 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', { diff --git a/apps/builder/src/features/billing/components/ChangePlanModal.tsx b/apps/builder/src/features/billing/components/ChangePlanModal.tsx index db536a571..70a36d9c7 100644 --- a/apps/builder/src/features/billing/components/ChangePlanModal.tsx +++ b/apps/builder/src/features/billing/components/ChangePlanModal.tsx @@ -25,7 +25,7 @@ export const ChangePlanModal = ({ type, }: ChangePlanModalProps) => { const t = useI18n() - const { workspace, refreshWorkspace } = useWorkspace() + const { workspace } = useWorkspace() return ( @@ -36,12 +36,7 @@ export const ChangePlanModal = ({ {t('billing.upgradeLimitLabel', { type: type })} )} - {workspace && ( - - )} + {workspace && } diff --git a/apps/builder/src/features/workspace/WorkspaceProvider.tsx b/apps/builder/src/features/workspace/WorkspaceProvider.tsx index a2da3006c..805ef4f4c 100644 --- a/apps/builder/src/features/workspace/WorkspaceProvider.tsx +++ b/apps/builder/src/features/workspace/WorkspaceProvider.tsx @@ -25,7 +25,6 @@ const workspaceContext = createContext<{ createWorkspace: (name?: string) => Promise updateWorkspace: (updates: { icon?: string; name?: string }) => void deleteCurrentWorkspace: () => Promise - 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 ( {children} diff --git a/apps/builder/src/features/workspace/helpers/isAdminWriteWorkspaceForbidden.ts b/apps/builder/src/features/workspace/helpers/isAdminWriteWorkspaceForbidden.ts index 108219f5d..2522009d1 100644 --- a/apps/builder/src/features/workspace/helpers/isAdminWriteWorkspaceForbidden.ts +++ b/apps/builder/src/features/workspace/helpers/isAdminWriteWorkspaceForbidden.ts @@ -2,7 +2,7 @@ import { MemberInWorkspace, User } from '@typebot.io/prisma' export const isAdminWriteWorkspaceForbidden = ( workspace: { - members: MemberInWorkspace[] + members: Pick[] }, user: Pick ) => { diff --git a/apps/builder/src/pages/api/stripe/webhook.ts b/apps/builder/src/pages/api/stripe/webhook.ts index 9f0ac4d56..0b58211c8 100644 --- a/apps/builder/src/pages/api/stripe/webhook.ts +++ b/apps/builder/src/pages/api/stripe/webhook.ts @@ -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,