2
0

🐛 (stripe) Fix plan update and management

This commit is contained in:
Baptiste Arnaud
2022-09-20 19:15:47 +02:00
committed by Baptiste Arnaud
parent f83e0efea2
commit 6384a3adae
12 changed files with 99 additions and 105 deletions

View File

@@ -7,6 +7,7 @@ import {
Button, Button,
Heading, Heading,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { useToast } from 'components/shared/hooks/useToast'
import { PlanTag } from 'components/shared/PlanTag' import { PlanTag } from 'components/shared/PlanTag'
import { Plan } from 'db' import { Plan } from 'db'
import React, { useState } from 'react' import React, { useState } from 'react'
@@ -26,38 +27,48 @@ export const CurrentSubscriptionContent = ({
const [isCancelling, setIsCancelling] = useState(false) const [isCancelling, setIsCancelling] = useState(false)
const [isRedirectingToBillingPortal, setIsRedirectingToBillingPortal] = const [isRedirectingToBillingPortal, setIsRedirectingToBillingPortal] =
useState(false) useState(false)
const { showToast } = useToast()
const cancelSubscription = async () => { const cancelSubscription = async () => {
if (!stripeId) return if (!stripeId) return
setIsCancelling(true) setIsCancelling(true)
await cancelSubscriptionQuery(stripeId) const { error } = await cancelSubscriptionQuery(stripeId)
if (error) {
showToast({ description: error.message })
return
}
onCancelSuccess() onCancelSuccess()
setIsCancelling(false) setIsCancelling(false)
} }
const isSubscribed = (plan === Plan.STARTER || plan === Plan.PRO) && stripeId const isSubscribed = (plan === Plan.STARTER || plan === Plan.PRO) && stripeId
if (isCancelling) return <Spinner colorScheme="gray" />
return ( return (
<Stack gap="2"> <Stack gap="2">
<Heading fontSize="3xl">Subscription</Heading> <Heading fontSize="3xl">Subscription</Heading>
<HStack> <HStack>
<Text>Current workspace subscription: </Text> <Text>Current workspace subscription: </Text>
<PlanTag plan={plan} /> {isCancelling ? (
{isSubscribed && ( <Spinner color="gray.500" size="xs" />
<Link ) : (
as="button" <>
color="gray.500" <PlanTag plan={plan} />
textDecor="underline" {isSubscribed && (
fontSize="sm" <Link
onClick={cancelSubscription} as="button"
> color="gray.500"
Cancel my subscription textDecor="underline"
</Link> fontSize="sm"
onClick={cancelSubscription}
>
Cancel my subscription
</Link>
)}
</>
)} )}
</HStack> </HStack>
{isSubscribed && ( {isSubscribed && !isCancelling && (
<> <>
<Stack gap="1"> <Stack gap="1">
<Text fontSize="sm"> <Text fontSize="sm">

View File

@@ -35,7 +35,7 @@ export const ChangePlanForm = () => {
selectedStorageLimitIndex === undefined selectedStorageLimitIndex === undefined
) )
return return
await pay({ const response = await pay({
stripeId: workspace.stripeId ?? undefined, stripeId: workspace.stripeId ?? undefined,
user, user,
plan, plan,
@@ -43,6 +43,10 @@ export const ChangePlanForm = () => {
additionalChats: selectedChatsLimitIndex, additionalChats: selectedChatsLimitIndex,
additionalStorage: selectedStorageLimitIndex, additionalStorage: selectedStorageLimitIndex,
}) })
if (typeof response === 'object' && response?.error) {
showToast({ description: response.error.message })
return
}
refreshCurrentSubscriptionInfo({ refreshCurrentSubscriptionInfo({
additionalChatsIndex: selectedChatsLimitIndex, additionalChatsIndex: selectedChatsLimitIndex,
additionalStorageIndex: selectedStorageLimitIndex, additionalStorageIndex: selectedStorageLimitIndex,

View File

@@ -20,7 +20,7 @@ type UpgradeProps = {
export const pay = async ({ export const pay = async ({
stripeId, stripeId,
...props ...props
}: UpgradeProps): Promise<{ newPlan: Plan } | undefined | void> => }: UpgradeProps): Promise<{ newPlan?: Plan; error?: Error } | void> =>
isDefined(stripeId) isDefined(stripeId)
? updatePlan({ ...props, stripeId }) ? updatePlan({ ...props, stripeId })
: redirectToCheckout(props) : redirectToCheckout(props)
@@ -31,13 +31,13 @@ export const updatePlan = async ({
workspaceId, workspaceId,
additionalChats, additionalChats,
additionalStorage, additionalStorage,
}: Omit<UpgradeProps, 'user'>): Promise<{ newPlan: Plan } | undefined> => { }: Omit<UpgradeProps, 'user'>): Promise<{ newPlan?: Plan; error?: Error }> => {
const { data, error } = await sendRequest<{ message: string }>({ const { data, error } = await sendRequest<{ message: string }>({
method: 'PUT', method: 'PUT',
url: '/api/stripe/subscription', url: '/api/stripe/subscription',
body: { workspaceId, plan, stripeId, additionalChats, additionalStorage }, body: { workspaceId, plan, stripeId, additionalChats, additionalStorage },
}) })
if (error || !data) return if (error || !data) return { error }
return { newPlan: plan } return { newPlan: plan }
} }

View File

@@ -18,6 +18,7 @@ import { SupportBubble } from 'components/shared/SupportBubble'
import { WorkspaceContext } from 'contexts/WorkspaceContext' import { WorkspaceContext } from 'contexts/WorkspaceContext'
import { toTitleCase } from 'utils' import { toTitleCase } from 'utils'
import { Session } from 'next-auth' import { Session } from 'next-auth'
import { Plan } from 'db'
const { ToastContainer, toast } = createStandaloneToast(customTheme) const { ToastContainer, toast } = createStandaloneToast(customTheme)
@@ -35,7 +36,14 @@ const App = ({
}, [pathname]) }, [pathname])
useEffect(() => { useEffect(() => {
displayStripeCallbackMessage(query.stripe?.toString(), toast) const newPlan = query.stripe?.toString()
if (newPlan === Plan.STARTER || newPlan === Plan.PRO)
toast({
position: 'bottom-right',
status: 'success',
title: 'Upgrade success!',
description: `Workspace upgraded to ${toTitleCase(status)} 🎉`,
})
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isReady]) }, [isReady])
@@ -68,19 +76,4 @@ const App = ({
) )
} }
const displayStripeCallbackMessage = (
status: string | undefined,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
toast: any
) => {
if (status && ['pro', 'team'].includes(status)) {
toast({
position: 'bottom-right',
status: 'success',
title: 'Upgrade success!',
description: `Workspace upgraded to ${toTitleCase(status)} 🎉`,
})
}
}
export default App export default App

View File

@@ -54,12 +54,12 @@ const getSubscriptionDetails =
}) })
return { return {
additionalChatsIndex: additionalChatsIndex:
subscriptions.data[0].items.data.find( subscriptions.data[0]?.items.data.find(
(item) => (item) =>
item.price.id === process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID item.price.id === process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID
)?.quantity ?? 0, )?.quantity ?? 0,
additionalStorageIndex: additionalStorageIndex:
subscriptions.data[0].items.data.find( subscriptions.data[0]?.items.data.find(
(item) => (item) =>
item.price.id === process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID item.price.id === process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
)?.quantity ?? 0, )?.quantity ?? 0,
@@ -100,33 +100,34 @@ const createCheckoutSession = (req: NextApiRequest) => {
} }
const updateSubscription = async (req: NextApiRequest) => { const updateSubscription = async (req: NextApiRequest) => {
const { customerId, plan, workspaceId, additionalChats, additionalStorage } = const { stripeId, plan, workspaceId, additionalChats, additionalStorage } = (
(typeof req.body === 'string' ? JSON.parse(req.body) : req.body) as { typeof req.body === 'string' ? JSON.parse(req.body) : req.body
customerId: string ) as {
workspaceId: string stripeId: string
additionalChats: number workspaceId: string
additionalStorage: number additionalChats: number
plan: 'STARTER' | 'PRO' additionalStorage: number
} plan: 'STARTER' | 'PRO'
}
if (!process.env.STRIPE_SECRET_KEY) if (!process.env.STRIPE_SECRET_KEY)
throw Error('STRIPE_SECRET_KEY var is missing') throw Error('STRIPE_SECRET_KEY var is missing')
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2022-08-01', apiVersion: '2022-08-01',
}) })
const { data } = await stripe.subscriptions.list({ const { data } = await stripe.subscriptions.list({
customer: customerId, customer: stripeId,
}) })
const subscription = data[0] const subscription = data[0] as Stripe.Subscription | undefined
const currentStarterPlanItemId = subscription.items.data.find( const currentStarterPlanItemId = subscription?.items.data.find(
(item) => item.price.id === process.env.STRIPE_STARTER_PRICE_ID (item) => item.price.id === process.env.STRIPE_STARTER_PRICE_ID
)?.id )?.id
const currentProPlanItemId = subscription.items.data.find( const currentProPlanItemId = subscription?.items.data.find(
(item) => item.price.id === process.env.STRIPE_PRO_PRICE_ID (item) => item.price.id === process.env.STRIPE_PRO_PRICE_ID
)?.id )?.id
const currentAdditionalChatsItemId = subscription.items.data.find( const currentAdditionalChatsItemId = subscription?.items.data.find(
(item) => item.price.id === process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID (item) => item.price.id === process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID
)?.id )?.id
const currentAdditionalStorageItemId = subscription.items.data.find( const currentAdditionalStorageItemId = subscription?.items.data.find(
(item) => item.price.id === process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID (item) => item.price.id === process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
)?.id )?.id
const items = [ const items = [
@@ -155,9 +156,18 @@ const updateSubscription = async (req: NextApiRequest) => {
deleted: additionalStorage === 0, deleted: additionalStorage === 0,
}, },
].filter(isDefined) ].filter(isDefined)
await stripe.subscriptions.update(subscription.id, {
items, if (subscription) {
}) await stripe.subscriptions.update(subscription.id, {
items,
})
} else {
await stripe.subscriptions.create({
customer: stripeId,
items,
})
}
await prisma.workspace.update({ await prisma.workspace.update({
where: { id: workspaceId }, where: { id: workspaceId },
data: { data: {
@@ -187,7 +197,10 @@ const cancelSubscription =
const existingSubscription = await stripe.subscriptions.list({ const existingSubscription = await stripe.subscriptions.list({
customer: workspace.stripeId, customer: workspace.stripeId,
}) })
await stripe.subscriptions.del(existingSubscription.data[0].id) const currentSubscriptionId = existingSubscription.data[0]?.id
if (currentSubscriptionId)
await stripe.subscriptions.del(currentSubscriptionId)
await prisma.workspace.update({ await prisma.workspace.update({
where: { id: workspace.id }, where: { id: workspace.id },
data: { data: {

View File

@@ -18,7 +18,7 @@ import {
Workspace, Workspace,
} from 'db' } from 'db'
import { readFileSync } from 'fs' import { readFileSync } from 'fs'
import { createFakeResults } from 'utils' import { injectFakeResults } from 'utils'
import { encrypt } from 'utils/api' import { encrypt } from 'utils/api'
import Stripe from 'stripe' import Stripe from 'stripe'
@@ -75,7 +75,10 @@ export const addSubscriptionToWorkspace = async (
customer: stripeId, customer: stripeId,
items, items,
default_payment_method: paymentId, default_payment_method: paymentId,
currency: 'usd', currency: 'eur',
})
await stripe.customers.update(stripeId, {
invoice_settings: { default_payment_method: paymentId },
}) })
await prisma.workspace.update({ await prisma.workspace.update({
where: { id: workspaceId }, where: { id: workspaceId },
@@ -264,7 +267,7 @@ export const updateUser = (data: Partial<User>) =>
}, },
}) })
export const createResults = createFakeResults(prisma) export const createResults = injectFakeResults(prisma)
export const createFolder = (workspaceId: string, name: string) => export const createFolder = (workspaceId: string, name: string) =>
prisma.dashboardFolder.create({ prisma.dashboardFolder.create({

View File

@@ -10,7 +10,7 @@ import {
} from 'models' } from 'models'
import { GraphNavigation, Plan, PrismaClient, WorkspaceRole } from 'db' import { GraphNavigation, Plan, PrismaClient, WorkspaceRole } from 'db'
import { readFileSync } from 'fs' import { readFileSync } from 'fs'
import { createFakeResults } from 'utils' import { injectFakeResults } from 'utils'
import { encrypt } from 'utils/api' import { encrypt } from 'utils/api'
const prisma = new PrismaClient() const prisma = new PrismaClient()
@@ -221,7 +221,7 @@ export const importTypebotInDatabase = async (
}) })
} }
export const createResults = createFakeResults(prisma) export const createResults = injectFakeResults(prisma)
export const createSmtpCredentials = ( export const createSmtpCredentials = (
id: string, id: string,

View File

@@ -1,18 +1,23 @@
import { PrismaClient } from 'db' import { PrismaClient } from 'db'
import path from 'path' import path from 'path'
import fs from 'fs' import { injectFakeResults } from 'utils'
require('dotenv').config({ require('dotenv').config({
path: path.join( path: path.join(
__dirname, __dirname,
process.env.NODE_ENV === 'production' process.env.NODE_ENV === 'staging' ? '.env.staging' : '.env.local'
? '.env.production'
: process.env.NODE_ENV === 'staging'
? '.env.staging'
: '.env.local'
), ),
}) })
const main = async () => {} const prisma = new PrismaClient()
const main = async () => {
await injectFakeResults(prisma)({
count: 150,
typebotId: 'cl89sq4vb030109laivd9ck97',
isChronological: false,
idPrefix: 'batch2',
})
}
main().then() main().then()

View File

@@ -7,9 +7,7 @@
"scripts": { "scripts": {
"start:local": "ts-node index.ts", "start:local": "ts-node index.ts",
"start:staging": "NODE_ENV=staging ts-node index.ts", "start:staging": "NODE_ENV=staging ts-node index.ts",
"start:prod": "NODE_ENV=production ts-node index.ts", "start:prod": "NODE_ENV=production ts-node index.ts"
"start:workspaces:migration": "ts-node workspaceMigration.ts",
"start:workspaces:migration:recover": "ts-node workspaceMigrationRecover.ts"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "18.7.16", "@types/node": "18.7.16",
@@ -17,6 +15,6 @@
"models": "workspace:*", "models": "workspace:*",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "^4.8.3", "typescript": "^4.8.3",
"utils": "*" "utils": "workspace:*"
} }
} }

View File

@@ -1,33 +0,0 @@
import fs from 'fs'
export const prepareEmojis = () => {
const emojiData = JSON.parse(fs.readFileSync('./emojiData.json', 'utf8'))
const strippedEmojiData = {
'Smileys & Emotion': emojiData['Smileys & Emotion'].map(
(emoji: { emoji: any }) => emoji.emoji
),
'People & Body': emojiData['People & Body'].map(
(emoji: { emoji: any }) => emoji.emoji
),
'Animals & Nature': emojiData['Animals & Nature'].map(
(emoji: { emoji: any }) => emoji.emoji
),
'Food & Drink': emojiData['Food & Drink'].map(
(emoji: { emoji: any }) => emoji.emoji
),
'Travel & Places': emojiData['Travel & Places'].map(
(emoji: { emoji: any }) => emoji.emoji
),
Activities: emojiData['Activities'].map(
(emoji: { emoji: any }) => emoji.emoji
),
Objects: emojiData['Objects'].map((emoji: { emoji: any }) => emoji.emoji),
Symbols: emojiData['Symbols'].map((emoji: { emoji: any }) => emoji.emoji),
Flags: emojiData['Flags'].map((emoji: { emoji: any }) => emoji.emoji),
}
fs.writeFileSync(
'strippedEmojis.json',
JSON.stringify(strippedEmojiData),
'utf8'
)
}

View File

@@ -8,7 +8,7 @@ type CreateFakeResultsProps = {
fakeStorage?: number fakeStorage?: number
} }
export const createFakeResults = export const injectFakeResults =
(prisma: PrismaClient) => (prisma: PrismaClient) =>
async ({ async ({
count, count,

2
pnpm-lock.yaml generated
View File

@@ -520,7 +520,7 @@ importers:
models: workspace:* models: workspace:*
ts-node: ^10.9.1 ts-node: ^10.9.1
typescript: ^4.8.3 typescript: ^4.8.3
utils: '*' utils: workspace:*
devDependencies: devDependencies:
'@types/node': 18.7.16 '@types/node': 18.7.16
db: link:../db db: link:../db