(billing) Automatic usage-based billing (#924)

BREAKING CHANGE: Stripe environment variables simplified. Check out the
new configs to adapt your existing system.

Closes #906





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

**New Features:**
- Introduced a usage-based billing system, providing more flexibility
and options for users.
- Integrated with Stripe for a smoother and more secure payment process.
- Enhanced the user interface with improvements to the billing,
workspace, and pricing pages for a more intuitive experience.

**Improvements:**
- Simplified the billing logic, removing additional chats and yearly
billing for a more streamlined user experience.
- Updated email notifications to keep users informed about their usage
and limits.
- Improved pricing and currency formatting for better clarity and
understanding.

**Testing:**
- Updated tests and specifications to ensure the reliability of new
features and improvements.

**Note:** These changes aim to provide a more flexible and user-friendly
billing system, with clearer pricing and improved notifications. Users
should find the new system more intuitive and easier to navigate.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Baptiste Arnaud
2023-10-17 08:03:30 +02:00
committed by GitHub
parent a8c2deb258
commit 797751b418
55 changed files with 1589 additions and 1497 deletions

View File

@@ -5,15 +5,10 @@ import { TRPCError } from '@trpc/server'
import { Plan } from '@typebot.io/prisma'
import { workspaceSchema } from '@typebot.io/schemas'
import Stripe from 'stripe'
import { isDefined } from '@typebot.io/lib'
import { z } from 'zod'
import { getChatsLimit } from '@typebot.io/lib/pricing'
import { chatPriceIds } from './getSubscription'
import { createCheckoutSessionUrl } from './createCheckoutSession'
import { isAdminWriteWorkspaceForbidden } from '@/features/workspace/helpers/isAdminWriteWorkspaceForbidden'
import { getUsage } from '@typebot.io/lib/api/getUsage'
import { env } from '@typebot.io/env'
import { priceIds } from '@typebot.io/lib/api/pricing'
export const updateSubscription = authenticatedProcedure
.meta({
@@ -30,9 +25,7 @@ export const updateSubscription = authenticatedProcedure
returnUrl: z.string(),
workspaceId: z.string(),
plan: z.enum([Plan.STARTER, Plan.PRO]),
additionalChats: z.number(),
currency: z.enum(['usd', 'eur']),
isYearly: z.boolean(),
})
)
.output(
@@ -43,14 +36,7 @@ export const updateSubscription = authenticatedProcedure
)
.mutation(
async ({
input: {
workspaceId,
plan,
additionalChats,
currency,
isYearly,
returnUrl,
},
input: { workspaceId, plan, currency, returnUrl },
ctx: { user },
}) => {
if (!env.STRIPE_SECRET_KEY)
@@ -81,6 +67,7 @@ export const updateSubscription = authenticatedProcedure
code: 'NOT_FOUND',
message: 'Workspace not found',
})
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: '2022-11-15',
})
@@ -91,39 +78,57 @@ export const updateSubscription = authenticatedProcedure
})
const subscription = data[0] as Stripe.Subscription | undefined
const currentPlanItemId = subscription?.items.data.find((item) =>
[env.STRIPE_STARTER_PRODUCT_ID, env.STRIPE_PRO_PRODUCT_ID].includes(
item.price.product.toString()
[env.STRIPE_STARTER_PRICE_ID, env.STRIPE_PRO_PRICE_ID].includes(
item.price.id
)
)?.id
const currentAdditionalChatsItemId = subscription?.items.data.find(
(item) => chatPriceIds.includes(item.price.id)
const currentUsageItemId = subscription?.items.data.find(
(item) =>
item.price.id === env.STRIPE_STARTER_CHATS_PRICE_ID ||
item.price.id === env.STRIPE_PRO_CHATS_PRICE_ID
)?.id
const frequency = isYearly ? 'yearly' : 'monthly'
const items = [
{
id: currentPlanItemId,
price: priceIds[plan].base[frequency],
price:
plan === Plan.STARTER
? env.STRIPE_STARTER_PRICE_ID
: env.STRIPE_PRO_PRICE_ID,
quantity: 1,
},
additionalChats === 0 && !currentAdditionalChatsItemId
? undefined
: {
id: currentAdditionalChatsItemId,
price: priceIds[plan].chats[frequency],
quantity: getChatsLimit({
plan,
additionalChatsIndex: additionalChats,
customChatsLimit: null,
}),
deleted: subscription ? additionalChats === 0 : undefined,
},
].filter(isDefined)
{
id: currentUsageItemId,
price:
plan === Plan.STARTER
? env.STRIPE_STARTER_CHATS_PRICE_ID
: env.STRIPE_PRO_CHATS_PRICE_ID,
},
]
if (subscription) {
if (plan === 'STARTER') {
const totalChatsUsed = await prisma.result.count({
where: {
typebot: { workspaceId },
hasStarted: true,
createdAt: {
gte: new Date(subscription.current_period_start * 1000),
},
},
})
if (totalChatsUsed >= 4000) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
"You have collected more than 4000 chats during this billing cycle. You can't downgrade to the Starter.",
})
}
}
await stripe.subscriptions.update(subscription.id, {
items,
proration_behavior: 'always_invoice',
proration_behavior:
plan === 'PRO' ? 'always_invoice' : 'create_prorations',
})
} else {
const checkoutUrl = await createCheckoutSessionUrl(stripe)({
@@ -133,31 +138,16 @@ export const updateSubscription = authenticatedProcedure
currency,
plan,
returnUrl,
additionalChats,
isYearly,
})
return { checkoutUrl }
}
let isQuarantined = workspace.isQuarantined
if (isQuarantined) {
const newChatsLimit = getChatsLimit({
plan,
additionalChatsIndex: additionalChats,
customChatsLimit: null,
})
const { totalChatsUsed } = await getUsage(prisma)(workspaceId)
if (totalChatsUsed < newChatsLimit) isQuarantined = false
}
const updatedWorkspace = await prisma.workspace.update({
where: { id: workspaceId },
data: {
plan,
additionalChatsIndex: additionalChats,
isQuarantined,
isQuarantined: false,
},
})
@@ -168,7 +158,6 @@ export const updateSubscription = authenticatedProcedure
userId: user.id,
data: {
plan,
additionalChatsIndex: additionalChats,
},
},
])