⚡ (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:
@@ -4,7 +4,6 @@ import { TRPCError } from '@trpc/server'
|
||||
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'
|
||||
import { env } from '@typebot.io/env'
|
||||
|
||||
@@ -26,14 +25,12 @@ export const createCheckoutSession = authenticatedProcedure
|
||||
currency: z.enum(['usd', 'eur']),
|
||||
plan: z.enum([Plan.STARTER, Plan.PRO]),
|
||||
returnUrl: z.string(),
|
||||
additionalChats: z.number(),
|
||||
vat: z
|
||||
.object({
|
||||
type: z.string(),
|
||||
value: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
isYearly: z.boolean(),
|
||||
})
|
||||
)
|
||||
.output(
|
||||
@@ -43,17 +40,7 @@ export const createCheckoutSession = authenticatedProcedure
|
||||
)
|
||||
.mutation(
|
||||
async ({
|
||||
input: {
|
||||
vat,
|
||||
email,
|
||||
company,
|
||||
workspaceId,
|
||||
currency,
|
||||
plan,
|
||||
returnUrl,
|
||||
additionalChats,
|
||||
isYearly,
|
||||
},
|
||||
input: { vat, email, company, workspaceId, currency, plan, returnUrl },
|
||||
ctx: { user },
|
||||
}) => {
|
||||
if (!env.STRIPE_SECRET_KEY)
|
||||
@@ -116,8 +103,6 @@ export const createCheckoutSession = authenticatedProcedure
|
||||
currency,
|
||||
plan,
|
||||
returnUrl,
|
||||
additionalChats,
|
||||
isYearly,
|
||||
})
|
||||
|
||||
if (!checkoutUrl)
|
||||
@@ -138,22 +123,12 @@ type Props = {
|
||||
currency: 'usd' | 'eur'
|
||||
plan: 'STARTER' | 'PRO'
|
||||
returnUrl: string
|
||||
additionalChats: number
|
||||
isYearly: boolean
|
||||
userId: string
|
||||
}
|
||||
|
||||
export const createCheckoutSessionUrl =
|
||||
(stripe: Stripe) =>
|
||||
async ({
|
||||
customerId,
|
||||
workspaceId,
|
||||
currency,
|
||||
plan,
|
||||
returnUrl,
|
||||
additionalChats,
|
||||
isYearly,
|
||||
}: Props) => {
|
||||
async ({ customerId, workspaceId, currency, plan, returnUrl }: Props) => {
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
success_url: `${returnUrl}?stripe=${plan}&success=true`,
|
||||
cancel_url: `${returnUrl}?stripe=cancel`,
|
||||
@@ -167,12 +142,25 @@ export const createCheckoutSessionUrl =
|
||||
metadata: {
|
||||
workspaceId,
|
||||
plan,
|
||||
additionalChats,
|
||||
},
|
||||
currency,
|
||||
billing_address_collection: 'required',
|
||||
automatic_tax: { enabled: true },
|
||||
line_items: parseSubscriptionItems(plan, additionalChats, isYearly),
|
||||
line_items: [
|
||||
{
|
||||
price:
|
||||
plan === 'STARTER'
|
||||
? env.STRIPE_STARTER_PRICE_ID
|
||||
: env.STRIPE_PRO_PRICE_ID,
|
||||
quantity: 1,
|
||||
},
|
||||
{
|
||||
price:
|
||||
plan === 'STARTER'
|
||||
? env.STRIPE_STARTER_CHATS_PRICE_ID
|
||||
: env.STRIPE_PRO_CHATS_PRICE_ID,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
return session.url
|
||||
|
||||
@@ -5,7 +5,6 @@ import Stripe from 'stripe'
|
||||
import { z } from 'zod'
|
||||
import { subscriptionSchema } from '@typebot.io/schemas/features/billing/subscription'
|
||||
import { isReadWorkspaceFobidden } from '@/features/workspace/helpers/isReadWorkspaceFobidden'
|
||||
import { priceIds } from '@typebot.io/lib/api/pricing'
|
||||
import { env } from '@typebot.io/env'
|
||||
|
||||
export const getSubscription = authenticatedProcedure
|
||||
@@ -75,15 +74,14 @@ export const getSubscription = authenticatedProcedure
|
||||
|
||||
return {
|
||||
subscription: {
|
||||
currentBillingPeriod:
|
||||
subscriptionSchema.shape.currentBillingPeriod.parse({
|
||||
start: new Date(currentSubscription.current_period_start),
|
||||
end: new Date(currentSubscription.current_period_end),
|
||||
}),
|
||||
status: subscriptionSchema.shape.status.parse(
|
||||
currentSubscription.status
|
||||
),
|
||||
isYearly: currentSubscription.items.data.some((item) => {
|
||||
return (
|
||||
priceIds.STARTER.chats.yearly === item.price.id ||
|
||||
priceIds.PRO.chats.yearly === item.price.id
|
||||
)
|
||||
}),
|
||||
currency: currentSubscription.currency as 'usd' | 'eur',
|
||||
cancelDate: currentSubscription.cancel_at
|
||||
? new Date(currentSubscription.cancel_at * 1000)
|
||||
@@ -91,8 +89,3 @@ export const getSubscription = authenticatedProcedure
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
export const chatPriceIds = [priceIds.STARTER.chats.monthly]
|
||||
.concat(priceIds.STARTER.chats.yearly)
|
||||
.concat(priceIds.PRO.chats.monthly)
|
||||
.concat(priceIds.PRO.chats.yearly)
|
||||
|
||||
@@ -3,6 +3,8 @@ import { authenticatedProcedure } from '@/helpers/server/trpc'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { z } from 'zod'
|
||||
import { isReadWorkspaceFobidden } from '@/features/workspace/helpers/isReadWorkspaceFobidden'
|
||||
import { env } from '@typebot.io/env'
|
||||
import Stripe from 'stripe'
|
||||
|
||||
export const getUsage = authenticatedProcedure
|
||||
.meta({
|
||||
@@ -19,13 +21,15 @@ export const getUsage = authenticatedProcedure
|
||||
workspaceId: z.string(),
|
||||
})
|
||||
)
|
||||
.output(z.object({ totalChatsUsed: z.number() }))
|
||||
.output(z.object({ totalChatsUsed: z.number(), resetsAt: z.date() }))
|
||||
.query(async ({ input: { workspaceId }, ctx: { user } }) => {
|
||||
const workspace = await prisma.workspace.findFirst({
|
||||
where: {
|
||||
id: workspaceId,
|
||||
},
|
||||
select: {
|
||||
stripeId: true,
|
||||
plan: true,
|
||||
members: {
|
||||
select: {
|
||||
userId: true,
|
||||
@@ -42,19 +46,63 @@ export const getUsage = authenticatedProcedure
|
||||
message: 'Workspace not found',
|
||||
})
|
||||
|
||||
const now = new Date()
|
||||
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
if (
|
||||
!env.STRIPE_SECRET_KEY ||
|
||||
!workspace.stripeId ||
|
||||
(workspace.plan !== 'STARTER' && workspace.plan !== 'PRO')
|
||||
) {
|
||||
const now = new Date()
|
||||
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
|
||||
const totalChatsUsed = await prisma.result.count({
|
||||
where: {
|
||||
typebotId: { in: workspace.typebots.map((typebot) => typebot.id) },
|
||||
hasStarted: true,
|
||||
createdAt: {
|
||||
gte: firstDayOfMonth,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const firstDayOfNextMonth = new Date(
|
||||
firstDayOfMonth.getFullYear(),
|
||||
firstDayOfMonth.getMonth() + 1,
|
||||
1
|
||||
)
|
||||
return { totalChatsUsed, resetsAt: firstDayOfNextMonth }
|
||||
}
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: '2022-11-15',
|
||||
})
|
||||
|
||||
const subscriptions = await stripe.subscriptions.list({
|
||||
customer: workspace.stripeId,
|
||||
})
|
||||
|
||||
const currentSubscription = subscriptions.data
|
||||
.filter((sub) => ['past_due', 'active'].includes(sub.status))
|
||||
.sort((a, b) => a.created - b.created)
|
||||
.shift()
|
||||
|
||||
if (!currentSubscription)
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `No subscription found on workspace: ${workspaceId}`,
|
||||
})
|
||||
|
||||
const totalChatsUsed = await prisma.result.count({
|
||||
where: {
|
||||
typebotId: { in: workspace.typebots.map((typebot) => typebot.id) },
|
||||
hasStarted: true,
|
||||
createdAt: {
|
||||
gte: firstDayOfMonth,
|
||||
gte: new Date(currentSubscription.current_period_start * 1000),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
totalChatsUsed,
|
||||
resetsAt: new Date(currentSubscription.current_period_end * 1000),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -64,12 +64,12 @@ export const listInvoices = authenticatedProcedure
|
||||
.filter(
|
||||
(invoice) => isDefined(invoice.invoice_pdf) && isDefined(invoice.id)
|
||||
)
|
||||
.map((i) => ({
|
||||
id: i.number as string,
|
||||
url: i.invoice_pdf as string,
|
||||
amount: i.subtotal,
|
||||
currency: i.currency,
|
||||
date: i.status_transitions.paid_at,
|
||||
.map((invoice) => ({
|
||||
id: invoice.number as string,
|
||||
url: invoice.invoice_pdf as string,
|
||||
amount: invoice.subtotal,
|
||||
currency: invoice.currency,
|
||||
date: invoice.status_transitions.paid_at,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user