2
0

(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

@@ -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,
},
},
}

View 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[]

View 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)
}

View 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]
}

View 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]
}

View 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)
})
}

View File

@@ -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"
}
}

View File

@@ -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)
}