⚡ (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:
278
packages/scripts/migrateSubscriptionsToUsageBased.ts
Normal file
278
packages/scripts/migrateSubscriptionsToUsageBased.ts
Normal file
@ -0,0 +1,278 @@
|
||||
import { PrismaClient } from '@typebot.io/prisma'
|
||||
import { promptAndSetEnvironment } from './utils'
|
||||
import { Stripe } from 'stripe'
|
||||
import { createId } from '@paralleldrive/cuid2'
|
||||
import { writeFileSync } from 'fs'
|
||||
|
||||
const migrateSubscriptionsToUsageBased = async () => {
|
||||
await promptAndSetEnvironment()
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
if (
|
||||
!process.env.STRIPE_STARTER_CHATS_PRICE_ID ||
|
||||
!process.env.STRIPE_PRO_CHATS_PRICE_ID ||
|
||||
!process.env.STRIPE_SECRET_KEY ||
|
||||
!process.env.STRIPE_STARTER_PRICE_ID ||
|
||||
!process.env.STRIPE_PRO_PRICE_ID
|
||||
)
|
||||
throw new Error('Missing some env variables')
|
||||
|
||||
const {
|
||||
starterChatsPriceId,
|
||||
proChatsPriceId,
|
||||
secretKey,
|
||||
starterPriceId,
|
||||
proPriceId,
|
||||
starterYearlyPriceId,
|
||||
proYearlyPriceId,
|
||||
} = {
|
||||
starterChatsPriceId: process.env.STRIPE_STARTER_CHATS_PRICE_ID,
|
||||
proChatsPriceId: process.env.STRIPE_PRO_CHATS_PRICE_ID,
|
||||
secretKey: process.env.STRIPE_SECRET_KEY,
|
||||
starterPriceId: process.env.STRIPE_STARTER_PRICE_ID,
|
||||
proPriceId: process.env.STRIPE_PRO_PRICE_ID,
|
||||
starterYearlyPriceId: process.env.STRIPE_STARTER_YEARLY_PRICE_ID,
|
||||
proYearlyPriceId: process.env.STRIPE_PRO_YEARLY_PRICE_ID,
|
||||
}
|
||||
|
||||
const workspacesWithPaidPlan = await prisma.workspace.findMany({
|
||||
where: {
|
||||
plan: {
|
||||
in: ['PRO', 'STARTER'],
|
||||
},
|
||||
isSuspended: false,
|
||||
},
|
||||
select: {
|
||||
plan: true,
|
||||
name: true,
|
||||
id: true,
|
||||
stripeId: true,
|
||||
isQuarantined: true,
|
||||
members: {
|
||||
select: {
|
||||
user: {
|
||||
select: { email: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
writeFileSync(
|
||||
'./workspacesWithPaidPlan.json',
|
||||
JSON.stringify(workspacesWithPaidPlan, null, 2)
|
||||
)
|
||||
|
||||
const stripe = new Stripe(secretKey, {
|
||||
apiVersion: '2022-11-15',
|
||||
})
|
||||
|
||||
const todayMidnight = new Date()
|
||||
todayMidnight.setUTCHours(0, 0, 0, 0)
|
||||
|
||||
const failedWorkspaces = []
|
||||
const workspacesWithoutSubscription = []
|
||||
const workspacesWithoutStripeId = []
|
||||
|
||||
let i = 0
|
||||
for (const workspace of workspacesWithPaidPlan) {
|
||||
i += 1
|
||||
console.log(
|
||||
`(${i} / ${workspacesWithPaidPlan.length})`,
|
||||
'Migrating workspace:',
|
||||
workspace.id,
|
||||
workspace.name,
|
||||
workspace.stripeId,
|
||||
JSON.stringify(workspace.members.map((member) => member.user.email))
|
||||
)
|
||||
if (!workspace.stripeId) {
|
||||
console.log('No stripe ID, skipping...')
|
||||
workspacesWithoutStripeId.push(workspace)
|
||||
writeFileSync(
|
||||
'./workspacesWithoutStripeId.json',
|
||||
JSON.stringify(workspacesWithoutStripeId, null, 2)
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
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) {
|
||||
console.log('No current subscription in workspace:', workspace.id)
|
||||
workspacesWithoutSubscription.push(workspace)
|
||||
writeFileSync(
|
||||
'./workspacesWithoutSubscription.json',
|
||||
JSON.stringify(workspacesWithoutSubscription)
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
currentSubscription.items.data.find(
|
||||
(item) =>
|
||||
item.price.id === starterChatsPriceId ||
|
||||
item.price.id === proChatsPriceId
|
||||
)
|
||||
) {
|
||||
console.log(
|
||||
'Already migrated to usage based billing for workspace. Skipping...'
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (
|
||||
!currentSubscription.items.data.find(
|
||||
(item) =>
|
||||
item.price.id === starterPriceId ||
|
||||
item.price.id === proPriceId ||
|
||||
item.price.id === starterYearlyPriceId ||
|
||||
item.price.id === proYearlyPriceId
|
||||
)
|
||||
) {
|
||||
console.log(
|
||||
'Could not find STARTER or PRO plan in items for workspace:',
|
||||
workspace.id
|
||||
)
|
||||
failedWorkspaces.push(workspace)
|
||||
writeFileSync(
|
||||
'./failedWorkspaces.json',
|
||||
JSON.stringify(failedWorkspaces, null, 2)
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const newSubscription = await stripe.subscriptions.update(
|
||||
currentSubscription.id,
|
||||
{
|
||||
items: [
|
||||
...currentSubscription.items.data.flatMap<Stripe.SubscriptionUpdateParams.Item>(
|
||||
(item) => {
|
||||
if (
|
||||
item.price.id === starterPriceId ||
|
||||
item.price.id === proPriceId
|
||||
)
|
||||
return {
|
||||
id: item.id,
|
||||
price: item.price.id,
|
||||
quantity: item.quantity,
|
||||
}
|
||||
if (
|
||||
item.price.id === starterYearlyPriceId ||
|
||||
item.price.id === proYearlyPriceId
|
||||
)
|
||||
return [
|
||||
{
|
||||
id: item.id,
|
||||
price: item.price.id,
|
||||
quantity: item.quantity,
|
||||
deleted: true,
|
||||
},
|
||||
{
|
||||
price:
|
||||
workspace.plan === 'STARTER'
|
||||
? starterPriceId
|
||||
: proPriceId,
|
||||
quantity: 1,
|
||||
},
|
||||
]
|
||||
return {
|
||||
id: item.id,
|
||||
price: item.price.id,
|
||||
quantity: item.quantity,
|
||||
deleted: true,
|
||||
}
|
||||
}
|
||||
),
|
||||
{
|
||||
price:
|
||||
workspace.plan === 'STARTER'
|
||||
? starterChatsPriceId
|
||||
: proChatsPriceId,
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
const totalResults = await prisma.result.count({
|
||||
where: {
|
||||
typebot: { workspaceId: workspace.id },
|
||||
hasStarted: true,
|
||||
createdAt: {
|
||||
gte: new Date(newSubscription.current_period_start * 1000),
|
||||
lt: todayMidnight,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (workspace.plan === 'STARTER' && totalResults >= 4000) {
|
||||
console.log(
|
||||
'Workspace has more than 4000 chats, automatically upgrading to PRO plan'
|
||||
)
|
||||
const currentPlanItemId = newSubscription?.items.data.find((item) =>
|
||||
[starterPriceId, proPriceId].includes(item.price.id)
|
||||
)?.id
|
||||
|
||||
if (!currentPlanItemId)
|
||||
throw new Error(`Could not find current plan item ID for workspace`)
|
||||
|
||||
await stripe.subscriptions.update(newSubscription.id, {
|
||||
items: [
|
||||
{
|
||||
id: currentPlanItemId,
|
||||
price: proPriceId,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await prisma.workspace.update({
|
||||
where: { id: workspace.id },
|
||||
data: {
|
||||
plan: 'PRO',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const subscriptionItem = newSubscription.items.data.find(
|
||||
(item) =>
|
||||
item.price.id === starterChatsPriceId ||
|
||||
item.price.id === proChatsPriceId
|
||||
)
|
||||
|
||||
if (!subscriptionItem)
|
||||
throw new Error(
|
||||
`Could not find subscription item for workspace ${workspace.id}`
|
||||
)
|
||||
|
||||
const idempotencyKey = createId()
|
||||
|
||||
console.log('Reporting total results:', totalResults)
|
||||
await stripe.subscriptionItems.createUsageRecord(
|
||||
subscriptionItem.id,
|
||||
{
|
||||
quantity: totalResults,
|
||||
timestamp: 'now',
|
||||
},
|
||||
{
|
||||
idempotencyKey,
|
||||
}
|
||||
)
|
||||
|
||||
if (workspace.isQuarantined) {
|
||||
await prisma.workspace.update({
|
||||
where: { id: workspace.id },
|
||||
data: {
|
||||
isQuarantined: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
migrateSubscriptionsToUsageBased()
|
Reference in New Issue
Block a user