⚡ (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:
@@ -1,33 +0,0 @@
|
||||
import { env } from '@typebot.io/env'
|
||||
import { Plan } from '@typebot.io/prisma'
|
||||
|
||||
export const priceIds = {
|
||||
[Plan.STARTER]: {
|
||||
base: {
|
||||
monthly: env.STRIPE_STARTER_MONTHLY_PRICE_ID,
|
||||
yearly: env.STRIPE_STARTER_YEARLY_PRICE_ID,
|
||||
},
|
||||
chats: {
|
||||
monthly: env.STRIPE_STARTER_CHATS_MONTHLY_PRICE_ID,
|
||||
yearly: env.STRIPE_STARTER_CHATS_YEARLY_PRICE_ID,
|
||||
},
|
||||
storage: {
|
||||
monthly: env.STRIPE_STARTER_STORAGE_MONTHLY_PRICE_ID,
|
||||
yearly: env.STRIPE_STARTER_STORAGE_YEARLY_PRICE_ID,
|
||||
},
|
||||
},
|
||||
[Plan.PRO]: {
|
||||
base: {
|
||||
monthly: env.STRIPE_PRO_MONTHLY_PRICE_ID,
|
||||
yearly: env.STRIPE_PRO_YEARLY_PRICE_ID,
|
||||
},
|
||||
chats: {
|
||||
monthly: env.STRIPE_PRO_CHATS_MONTHLY_PRICE_ID,
|
||||
yearly: env.STRIPE_PRO_CHATS_YEARLY_PRICE_ID,
|
||||
},
|
||||
storage: {
|
||||
monthly: env.STRIPE_PRO_STORAGE_MONTHLY_PRICE_ID,
|
||||
yearly: env.STRIPE_PRO_STORAGE_YEARLY_PRICE_ID,
|
||||
},
|
||||
},
|
||||
}
|
||||
167
packages/lib/billing/constants.ts
Normal file
167
packages/lib/billing/constants.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { Plan } from '@typebot.io/prisma'
|
||||
import type { Stripe } from 'stripe'
|
||||
|
||||
export const prices = {
|
||||
[Plan.STARTER]: 39,
|
||||
[Plan.PRO]: 89,
|
||||
} as const
|
||||
|
||||
export const chatsLimits = {
|
||||
[Plan.FREE]: 200,
|
||||
[Plan.STARTER]: 2000,
|
||||
[Plan.PRO]: 10000,
|
||||
} as const
|
||||
|
||||
export const seatsLimits = {
|
||||
[Plan.FREE]: 1,
|
||||
[Plan.OFFERED]: 1,
|
||||
[Plan.STARTER]: 2,
|
||||
[Plan.PRO]: 5,
|
||||
[Plan.LIFETIME]: 8,
|
||||
} as const
|
||||
|
||||
export const starterChatTiers = [
|
||||
{
|
||||
up_to: 2000,
|
||||
flat_amount: 0,
|
||||
},
|
||||
{
|
||||
up_to: 2500,
|
||||
flat_amount: 1000,
|
||||
},
|
||||
{
|
||||
up_to: 3000,
|
||||
flat_amount: 1000,
|
||||
},
|
||||
{
|
||||
up_to: 3500,
|
||||
flat_amount: 1000,
|
||||
},
|
||||
{
|
||||
up_to: 4000,
|
||||
flat_amount: 1000,
|
||||
},
|
||||
{
|
||||
up_to: 'inf',
|
||||
unit_amount: 2,
|
||||
},
|
||||
] satisfies Stripe.PriceCreateParams.Tier[]
|
||||
|
||||
export const proChatTiers = [
|
||||
{
|
||||
up_to: 10000,
|
||||
flat_amount: 0,
|
||||
},
|
||||
{
|
||||
up_to: 15000,
|
||||
flat_amount: 5000,
|
||||
},
|
||||
{
|
||||
up_to: 20000,
|
||||
flat_amount: 4500,
|
||||
},
|
||||
{
|
||||
up_to: 30000,
|
||||
flat_amount: 8500,
|
||||
},
|
||||
{
|
||||
up_to: 40000,
|
||||
flat_amount: 8000,
|
||||
},
|
||||
{
|
||||
up_to: 50000,
|
||||
flat_amount: 7500,
|
||||
},
|
||||
{
|
||||
up_to: 60000,
|
||||
flat_amount: 7225,
|
||||
},
|
||||
{
|
||||
up_to: 70000,
|
||||
flat_amount: 7000,
|
||||
},
|
||||
{
|
||||
up_to: 80000,
|
||||
flat_amount: 6800,
|
||||
},
|
||||
{
|
||||
up_to: 90000,
|
||||
flat_amount: 6600,
|
||||
},
|
||||
{
|
||||
up_to: 100000,
|
||||
flat_amount: 6400,
|
||||
},
|
||||
{
|
||||
up_to: 120000,
|
||||
flat_amount: 12400,
|
||||
},
|
||||
{
|
||||
up_to: 140000,
|
||||
flat_amount: 12000,
|
||||
},
|
||||
{
|
||||
up_to: 160000,
|
||||
flat_amount: 11800,
|
||||
},
|
||||
{
|
||||
up_to: 180000,
|
||||
flat_amount: 11600,
|
||||
},
|
||||
{
|
||||
up_to: 200000,
|
||||
flat_amount: 11400,
|
||||
},
|
||||
{
|
||||
up_to: 300000,
|
||||
flat_amount: 55000,
|
||||
},
|
||||
{
|
||||
up_to: 400000,
|
||||
flat_amount: 53000,
|
||||
},
|
||||
{
|
||||
up_to: 500000,
|
||||
flat_amount: 51000,
|
||||
},
|
||||
{
|
||||
up_to: 600000,
|
||||
flat_amount: 50000,
|
||||
},
|
||||
{
|
||||
up_to: 700000,
|
||||
flat_amount: 49000,
|
||||
},
|
||||
{
|
||||
up_to: 800000,
|
||||
flat_amount: 48000,
|
||||
},
|
||||
{
|
||||
up_to: 900000,
|
||||
flat_amount: 47000,
|
||||
},
|
||||
{
|
||||
up_to: 1000000,
|
||||
flat_amount: 46000,
|
||||
},
|
||||
{
|
||||
up_to: 1200000,
|
||||
flat_amount: 91400,
|
||||
},
|
||||
{
|
||||
up_to: 1400000,
|
||||
flat_amount: 90800,
|
||||
},
|
||||
{
|
||||
up_to: 1600000,
|
||||
flat_amount: 90000,
|
||||
},
|
||||
{
|
||||
up_to: 1800000,
|
||||
flat_amount: 89400,
|
||||
},
|
||||
{
|
||||
up_to: 'inf',
|
||||
unit_amount_decimal: '0.442',
|
||||
},
|
||||
] satisfies Stripe.PriceCreateParams.Tier[]
|
||||
21
packages/lib/billing/formatPrice.ts
Normal file
21
packages/lib/billing/formatPrice.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { guessIfUserIsEuropean } from './guessIfUserIsEuropean'
|
||||
|
||||
type FormatPriceParams = {
|
||||
currency?: 'eur' | 'usd'
|
||||
maxFractionDigits?: number
|
||||
}
|
||||
|
||||
export const formatPrice = (
|
||||
price: number,
|
||||
{ currency, maxFractionDigits = 0 }: FormatPriceParams = {
|
||||
maxFractionDigits: 0,
|
||||
}
|
||||
) => {
|
||||
const isEuropean = guessIfUserIsEuropean()
|
||||
const formatter = new Intl.NumberFormat(isEuropean ? 'fr-FR' : 'en-US', {
|
||||
style: 'currency',
|
||||
currency: currency?.toUpperCase() ?? (isEuropean ? 'EUR' : 'USD'),
|
||||
maximumFractionDigits: maxFractionDigits,
|
||||
})
|
||||
return formatter.format(price)
|
||||
}
|
||||
19
packages/lib/billing/getChatsLimit.ts
Normal file
19
packages/lib/billing/getChatsLimit.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Plan } from '@typebot.io/prisma'
|
||||
import { chatsLimits } from './constants'
|
||||
import { Workspace } from '@typebot.io/schemas'
|
||||
|
||||
export const getChatsLimit = ({
|
||||
plan,
|
||||
customChatsLimit,
|
||||
}: Pick<Workspace, 'plan'> & {
|
||||
customChatsLimit?: Workspace['customChatsLimit']
|
||||
}) => {
|
||||
if (
|
||||
plan === Plan.UNLIMITED ||
|
||||
plan === Plan.LIFETIME ||
|
||||
plan === Plan.OFFERED
|
||||
)
|
||||
return -1
|
||||
if (plan === Plan.CUSTOM) return customChatsLimit ?? -1
|
||||
return chatsLimits[plan]
|
||||
}
|
||||
12
packages/lib/billing/getSeatsLimit.ts
Normal file
12
packages/lib/billing/getSeatsLimit.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Workspace } from '@typebot.io/schemas'
|
||||
import { seatsLimits } from './constants'
|
||||
import { Plan } from '@typebot.io/prisma'
|
||||
|
||||
export const getSeatsLimit = ({
|
||||
plan,
|
||||
customSeatsLimit,
|
||||
}: Pick<Workspace, 'plan' | 'customSeatsLimit'>) => {
|
||||
if (plan === Plan.UNLIMITED) return -1
|
||||
if (plan === Plan.CUSTOM) return customSeatsLimit ? customSeatsLimit : -1
|
||||
return seatsLimits[plan]
|
||||
}
|
||||
56
packages/lib/billing/guessIfUserIsEuropean.ts
Normal file
56
packages/lib/billing/guessIfUserIsEuropean.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
const europeanUnionCountryCodes = [
|
||||
'AT',
|
||||
'BE',
|
||||
'BG',
|
||||
'CY',
|
||||
'CZ',
|
||||
'DE',
|
||||
'DK',
|
||||
'EE',
|
||||
'ES',
|
||||
'FI',
|
||||
'FR',
|
||||
'GR',
|
||||
'HR',
|
||||
'HU',
|
||||
'IE',
|
||||
'IT',
|
||||
'LT',
|
||||
'LU',
|
||||
'LV',
|
||||
'MT',
|
||||
'NL',
|
||||
'PL',
|
||||
'PT',
|
||||
'RO',
|
||||
'SE',
|
||||
'SI',
|
||||
'SK',
|
||||
]
|
||||
|
||||
const europeanUnionExclusiveLanguageCodes = [
|
||||
'fr',
|
||||
'de',
|
||||
'it',
|
||||
'el',
|
||||
'pl',
|
||||
'fi',
|
||||
'nl',
|
||||
'hr',
|
||||
'cs',
|
||||
'hu',
|
||||
'ro',
|
||||
'sl',
|
||||
'sv',
|
||||
'bg',
|
||||
]
|
||||
|
||||
export const guessIfUserIsEuropean = () => {
|
||||
if (typeof window === 'undefined') return false
|
||||
return window.navigator.languages.some((language) => {
|
||||
const [languageCode, countryCode] = language.split('-')
|
||||
return countryCode
|
||||
? europeanUnionCountryCodes.includes(countryCode)
|
||||
: europeanUnionExclusiveLanguageCodes.includes(languageCode)
|
||||
})
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
"@udecode/plate-common": "^21.1.5",
|
||||
"got": "12.6.0",
|
||||
"minio": "7.1.3",
|
||||
"remark-slate": "^1.8.6"
|
||||
"remark-slate": "^1.8.6",
|
||||
"stripe": "12.13.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
import type { Workspace } from '@typebot.io/prisma'
|
||||
import { Plan } from '@typebot.io/prisma'
|
||||
|
||||
const infinity = -1
|
||||
|
||||
export const prices = {
|
||||
[Plan.STARTER]: 39,
|
||||
[Plan.PRO]: 89,
|
||||
} as const
|
||||
|
||||
export const chatsLimit = {
|
||||
[Plan.FREE]: { totalIncluded: 200 },
|
||||
[Plan.STARTER]: {
|
||||
graduatedPrice: [
|
||||
{ totalIncluded: 2000, price: 0 },
|
||||
{
|
||||
totalIncluded: 2500,
|
||||
price: 10,
|
||||
},
|
||||
{
|
||||
totalIncluded: 3000,
|
||||
price: 20,
|
||||
},
|
||||
{
|
||||
totalIncluded: 3500,
|
||||
price: 30,
|
||||
},
|
||||
],
|
||||
},
|
||||
[Plan.PRO]: {
|
||||
graduatedPrice: [
|
||||
{ totalIncluded: 10000, price: 0 },
|
||||
{ totalIncluded: 15000, price: 50 },
|
||||
{ totalIncluded: 25000, price: 150 },
|
||||
{ totalIncluded: 50000, price: 400 },
|
||||
],
|
||||
},
|
||||
[Plan.CUSTOM]: {
|
||||
totalIncluded: 2000,
|
||||
increaseStep: {
|
||||
amount: 500,
|
||||
price: 10,
|
||||
},
|
||||
},
|
||||
[Plan.OFFERED]: { totalIncluded: infinity },
|
||||
[Plan.LIFETIME]: { totalIncluded: infinity },
|
||||
[Plan.UNLIMITED]: { totalIncluded: infinity },
|
||||
} as const
|
||||
|
||||
export const seatsLimit = {
|
||||
[Plan.FREE]: { totalIncluded: 1 },
|
||||
[Plan.STARTER]: {
|
||||
totalIncluded: 2,
|
||||
},
|
||||
[Plan.PRO]: {
|
||||
totalIncluded: 5,
|
||||
},
|
||||
[Plan.CUSTOM]: {
|
||||
totalIncluded: 2,
|
||||
},
|
||||
[Plan.OFFERED]: { totalIncluded: 2 },
|
||||
[Plan.LIFETIME]: { totalIncluded: 8 },
|
||||
[Plan.UNLIMITED]: { totalIncluded: infinity },
|
||||
} as const
|
||||
|
||||
export const getChatsLimit = ({
|
||||
plan,
|
||||
additionalChatsIndex,
|
||||
customChatsLimit,
|
||||
}: Pick<Workspace, 'additionalChatsIndex' | 'plan' | 'customChatsLimit'>) => {
|
||||
if (customChatsLimit) return customChatsLimit
|
||||
const totalIncluded =
|
||||
plan === Plan.STARTER || plan === Plan.PRO
|
||||
? chatsLimit[plan].graduatedPrice[additionalChatsIndex].totalIncluded
|
||||
: chatsLimit[plan].totalIncluded
|
||||
return totalIncluded
|
||||
}
|
||||
|
||||
export const getSeatsLimit = ({
|
||||
plan,
|
||||
customSeatsLimit,
|
||||
}: Pick<Workspace, 'plan' | 'customSeatsLimit'>) => {
|
||||
if (customSeatsLimit) return customSeatsLimit
|
||||
return seatsLimit[plan].totalIncluded
|
||||
}
|
||||
|
||||
export const isSeatsLimitReached = ({
|
||||
existingMembersAndInvitationsCount,
|
||||
plan,
|
||||
customSeatsLimit,
|
||||
}: {
|
||||
existingMembersAndInvitationsCount: number
|
||||
} & Pick<Workspace, 'plan' | 'customSeatsLimit'>) => {
|
||||
const seatsLimit = getSeatsLimit({ plan, customSeatsLimit })
|
||||
return (
|
||||
seatsLimit !== infinity && seatsLimit <= existingMembersAndInvitationsCount
|
||||
)
|
||||
}
|
||||
|
||||
export const computePrice = (
|
||||
plan: Plan,
|
||||
selectedTotalChatsIndex: number,
|
||||
frequency: 'monthly' | 'yearly'
|
||||
) => {
|
||||
if (plan !== Plan.STARTER && plan !== Plan.PRO) return
|
||||
const price =
|
||||
prices[plan] +
|
||||
chatsLimit[plan].graduatedPrice[selectedTotalChatsIndex].price
|
||||
return frequency === 'monthly' ? price : price - price * 0.16
|
||||
}
|
||||
|
||||
const europeanUnionCountryCodes = [
|
||||
'AT',
|
||||
'BE',
|
||||
'BG',
|
||||
'CY',
|
||||
'CZ',
|
||||
'DE',
|
||||
'DK',
|
||||
'EE',
|
||||
'ES',
|
||||
'FI',
|
||||
'FR',
|
||||
'GR',
|
||||
'HR',
|
||||
'HU',
|
||||
'IE',
|
||||
'IT',
|
||||
'LT',
|
||||
'LU',
|
||||
'LV',
|
||||
'MT',
|
||||
'NL',
|
||||
'PL',
|
||||
'PT',
|
||||
'RO',
|
||||
'SE',
|
||||
'SI',
|
||||
'SK',
|
||||
]
|
||||
|
||||
const europeanUnionExclusiveLanguageCodes = [
|
||||
'fr',
|
||||
'de',
|
||||
'it',
|
||||
'el',
|
||||
'pl',
|
||||
'fi',
|
||||
'nl',
|
||||
'hr',
|
||||
'cs',
|
||||
'hu',
|
||||
'ro',
|
||||
'sl',
|
||||
'sv',
|
||||
'bg',
|
||||
]
|
||||
|
||||
export const guessIfUserIsEuropean = () => {
|
||||
if (typeof window === 'undefined') return false
|
||||
return window.navigator.languages.some((language) => {
|
||||
const [languageCode, countryCode] = language.split('-')
|
||||
return countryCode
|
||||
? europeanUnionCountryCodes.includes(countryCode)
|
||||
: europeanUnionExclusiveLanguageCodes.includes(languageCode)
|
||||
})
|
||||
}
|
||||
|
||||
export const formatPrice = (price: number, currency?: 'eur' | 'usd') => {
|
||||
const isEuropean = guessIfUserIsEuropean()
|
||||
const formatter = new Intl.NumberFormat(isEuropean ? 'fr-FR' : 'en-US', {
|
||||
style: 'currency',
|
||||
currency: currency?.toUpperCase() ?? (isEuropean ? 'EUR' : 'USD'),
|
||||
maximumFractionDigits: 0, // (causes 2500.99 to be printed as $2,501)
|
||||
})
|
||||
return formatter.format(price)
|
||||
}
|
||||
Reference in New Issue
Block a user