Stop rebuilding your billing system | Autumn
DocsBlogPricingDiscord
Start for free
Back to blogdiagram connectorscustomers<br>idplan_idcus_001pro_v2cus_002pro_v1<br>plans<br>idpricecreditsfree$050pro_v1$20200pro_v2$40400<br>entitlements<br>featurelimitseats5api_calls10k<br>schedules<br>customerchangecus_002→ pro_v2cus_004→ cancel
One of the hardest parts of software monetization is changing it.
Pricing is one of the highest leverage parts of a business to experiment with. Even more so today: AI and usage-based models are many times more dynamic than flat fees and seats.
But when you built your system, you hadn't planned for a pricing update each quarter. You just wanted to ship it quickly and get back to valuable features. That means every time your growth or sales team comes to you with an idea for pricing, you flinch because it's a month of rework.
We've been through this ourselves at Autumn — trying to build a system and data model to generalize pricing. We've made mistakes and seen scars from our customers, and so wanted to share our learnings on how to architect a billing system that requires as little maintenance and rebuild as possible.
Billing is a relational system
Most people think that billing data is just their customer data and thus only track that in their database. Other things like plan configurations end up being stored in code. So for instance, you might have a config file like the one below for your plans, and then a column on your user table indicating which plan they're on.<br>plans.tsclickVersion Pro
const plans = {<br>free: { price: 0, credits: 50 },<br>pro: { price: 20, credits: 200 },<br>};
This is definitely quick to ship, but let's now try to make a pricing change. Imagine we'd like to update the pro plan to cost $40 and grant 400 credits each month — Version Pro to update the config. On the customer side, we'd add another column for the version of the plan the customer is on.
Still relatively clean, but now you sign a custom deal with a user for them to pay $50/mo for 500 credits. To do this, we'd probably create another table called custom_plans and store the custom version of the user's plan in that table.
The problem with this is that our data is all over the place. Some plan data lives in code, while others live in the DB. There's no unified way to just fetch a customer and their plan info. The logic for this looks something like:
import plans from "@plans"
const userPlanInfo = user.customPlan ?? plans[user.regularPlan]<br>Compare this to storing all of your plan info, custom or not, in a Postgres table. To add a new plan version, or customize a plan for a user, you simply add a row to your plans table.
postgresVersion ProCustom contract
plans<br>idtypepricecreditsfreestandard$050pro_v1standard$20200customers<br>idemailplan_id →cus_01alex@acme.comacme_custom (missing)cus_02sam@beta.iopro_v2 (missing)-- fetch any customer with their plan in one query<br>select * from customers c<br>join plans p on p.id = c.plan_id;
Everything is centralized, you can fetch a user and their plan in a single query, and most importantly, it's extensible. For example, if you wanted to let customers choose different credit amounts on a plan, you could simply add a plan_credit_amounts table and relate it back to your existing plans and customers.
Entitlements should be data too
The same goes for entitlements. Hardcoding access like const hasPremiumModels = () => user.plan === 'pro' works until you need to grant one free user access, and you're back to conditional logic all over your codebase. Model entitlements as data instead: a plan grants them, customers inherit through their plan, with per-customer overrides when needed. An exception becomes a row change, not a code change.
The larger point here is that billing is really a set of relationships between your customers, plans, and entitlements, all of which evolve over time. If you treat it as such, you build a system that's much more robust and structured when it comes to pricing changes and save yourself the headache of re-building your system every time.
Billing is hierarchical
The next important thing to understand about billing is that it's hierarchical. The same piece of configuration can often be specific to a customer, a plan, or even global. By designing your system so that configuration can be applied at any of these levels while sharing the same underlying logic, you make pricing changes much cheaper and easier to manage.
Let's look at an example. Imagine you have a pro plan that grants 100 credits per month. If you have a mix of sales-led and PLG customers, you probably end up creating custom contracts that grant arbitrary credit amounts. In that case, it might seem like a good idea to store the number of credits each customer receives directly on the customer row. However, let's say you now want to make a change to increase the number of credits this plan usually grants to 200/month. To do this you have to update every single one of your customers that's not...