Saturday, June 7th 2025
Billing Overhaul
The billing system got completely overhauled, now supporting diverse plans, including lifetime and metered options, with flexible pricing models.
Schema
The billing configuration is composed of the following entities:
- Product: Defines the offering (e.g. Starter, Pro, Enterprise, etc.) with 1-n plans.
- Plan: Defines the payment plan (e.g. Pro Monthly, Pro Yearly. etc) with 1-n prices.
- Price: Defines the type, interval, model, amount and currency.
Following enums define a price more granulary:
- PriceType: Can be
recurring
orone-time
. - PriceInterval: Can be
month
,year
orundefined
. - PriceModel: Can be
flat
,per_seat
ormetered
.
Multiple prices are required if you want to combine multiple strategies, so more line items will be generate on the invoice.
Tactic Change
We've shifted our strategy for handling billing data:
- Previous Tactic: Store as little as possible and query everything directly from the billing provider.
- New Tactic: Store as much billing data as possible in our own database. This change is driven by the realization that billing providers can impose aggressive rate limits as user amounts increase.
Database Changes
To support this new tactic, we've made several important database schema changes:
- Organization: Now stores a billing customer (including billing email and billing address). Previous
tiers
have been removed. - Added Subscription: A new table to manage active subscriptions.
- Added SubscriptionItem: Details the individual items within a subscription.
- Added Order: A new table for one-time purchases.
- Added OrderItem: Details the individual items within an order.
Example Configuration
Below is an example of how a billingConfig
might be structured, showcasing Free
, Pro
, Lifetime
and Enterprise
products with their respective plans and prices:
packages/billing/src/config.ts
export const billingConfig = createBillingConfig({ products: [ { id: 'free', name: 'Free', description: 'Start for free.', label: 'Get started', isFree: true, features: [Feature.AICustomerScoring, Feature.SmartEmailAnalysis], // Even if it is free, keep the plans and prices to display the interval and currency correctly plans: [ { id: 'plan-free-month', displayIntervals: [PriceInterval.Month], prices: [ { id: 'price-free-month-id', // a placebo ID is fine here type: PriceType.Recurring, model: PriceModel.Flat, interval: PriceInterval.Month, cost: 0, currency } ] }, { id: 'plan-free-year', displayIntervals: [PriceInterval.Year], prices: [ { id: 'price-free-year-id', // a placebo ID is fine here interval: PriceInterval.Year, type: PriceType.Recurring, model: PriceModel.Flat, cost: 0, currency } ] } ] }, { id: 'pro', name: 'Pro', description: 'Best for most teams.', label: 'Get started', recommended: true, features: [ Feature.AICustomerScoring, Feature.SmartEmailAnalysis, Feature.SentimentAnalysis, Feature.LeadPredictions ], plans: [ { id: 'plan-pro-month', displayIntervals: [PriceInterval.Month], trialDays: 7, prices: [ { id: keys().NEXT_PUBLIC_BILLING_PRICE_PRO_MONTH_ID || 'price-pro-month-id', // keep for marketing pages, so you only need to specify id in dashboard interval: PriceInterval.Month, type: PriceType.Recurring, model: PriceModel.Flat, cost: 24, currency } ] }, { id: 'plan-pro-year', displayIntervals: [PriceInterval.Year], trialDays: 7, prices: [ { id: keys().NEXT_PUBLIC_BILLING_PRICE_PRO_YEAR_ID || 'price-pro-year-id', // keep for marketing pages, so you only need to specify id in dashboard interval: PriceInterval.Year, type: PriceType.Recurring, model: PriceModel.Flat, cost: 199, currency } ] } ] }, { id: 'lifetime', name: 'Lifetime', description: 'Buy once. Use forever.', label: 'Get started', features: [ Feature.AICustomerScoring, Feature.SmartEmailAnalysis, Feature.SentimentAnalysis, Feature.LeadPredictions ], plans: [ { id: 'lifetime', displayIntervals: [PriceInterval.Month, PriceInterval.Year], prices: [ { id: keys().NEXT_PUBLIC_BILLING_PRICE_LIFETIME_ID || 'price-lifetime-id', // keep for marketing pages, so you only need to specify id in dashboard type: PriceType.OneTime, model: PriceModel.Flat, // only flat is supported for PriceType.OneTime interval: undefined, cost: 699, currency } ] } ] }, { id: 'enterprise', name: 'Enterprise', description: 'Best for tailored requirements.', label: 'Contact us', isEnterprise: true, features: [ Feature.AICustomerScoring, Feature.SmartEmailAnalysis, Feature.SentimentAnalysis, Feature.LeadPredictions, Feature.DataStorage, Feature.ExtendedSupport ], // The idea is to keep the product and prices and use an admin panel to update the customer to enterprise. // For enterprise you can have multiple products, you just need to set hidden: true on the other enterprise products. plans: [ { id: 'plan-enterprise-month', displayIntervals: [PriceInterval.Month], prices: [ { id: keys().NEXT_PUBLIC_BILLING_PRICE_ENTERPRISE_MONTH_ID || 'price-enterprise-month-id', // keep for marketing pages, so you only need to specify id in dashboard interval: PriceInterval.Month, type: PriceType.Recurring, model: PriceModel.Flat, cost: 39, currency } ] }, { id: 'plan-enterprise-year', displayIntervals: [PriceInterval.Year], prices: [ { id: keys().NEXT_PUBLIC_BILLING_PRICE_ENTERPRISE_YEAR_ID || 'price-enterprise-year-id', // keep for marketing pages, so you only need to specify id in dashboard interval: PriceInterval.Year, type: PriceType.Recurring, model: PriceModel.Flat, cost: 399, currency } ] } ] } ]});