Subscription features for your SaaS
You might already have a side project that you intend to charge money for - or maybe it’s something you hope to have in the future. Or you can simply just use this post as inspiration for who knows what :)
The importance of your customers
Having a side project with paying users is one of my favorite things. Not because of the side income, but because you have identified a problem that other people also have which is big enough that people are willing to pay money for it. These people become your customers. They trust you to solve their problem and pay you monthly/yearly for your services.
One of the worst things you can do to lose their trust, is to change the pricing for a customer that is already signed up - or that’s at least a thing I believe is important not to do in order to keep their trust.
Products evolve
Products naturally evolve over time, they start simply and slowly grow in complexity/features. Hopefully you’re able to keep them somewhat simple - but it’s a slow evolution of trying things out. One thing is finding a problem that people are willing to pay money for you to solve - another one is to figure out the right combination of features bundled together in plans. Some people are really good at this, others like me are not and it takes a bit of trial and error.
I don’t want to charge a customer that trusted me early on for $5/mo to be “punished” when I later on find out that $5/mo isn’t the right price and that it instead should be $9/mo.
How to solve this problem?
For my side project (FormBackend)[https://www.formbackend.com] I tried solving this by modeling the plans, subscriptions and features in a way that I could relatively easily introduce new plans, and grandfather customers on old plans in under the original terms they signed up for.
I needed something that was flexible enough, that if a customer signed up to Plan A which included a subset of features and I later on wanted to either remove that plan or introduce a new plan - that things then would still continue to work without a lot of changes in the code.
I wanted something that if you viewed the pricing page and it said that Plan A included 2 forms
and 500 submissions/month
then there should be a 1:1 mapping between that feature table and how it worked in the code. The only
logical thing for me to do here - instead of having a spreadsheet where I tracked all the
features I listed on the pricing page and mapped them 1:1 to feature checks in my code -
was to tightly couple how the pricing page was generated and how I did the feature
checks in the code.
The data structure
Let’s get a little closer to what you’re all waiting for, which is the actual code. How do I intend to solve this?
We have an Account
which is the root object for a customer. An account has many users, a subscription etc.
When a user subscribes to something, in the SaaS world that’s often some sort of Plan
with a set of features.
So what I want in my code, is something along the lines of an Account
has a Subcription
which has a Plan
that has many features
:
class Plan < ApplicationRecord
belongs_to :subscription
has_many :features
end
What is a feature?
A feature is something that gives the customer access to do something in your app. It could be a boolean feature that makes it so a customer can redirect a user to a URL when they submits a form on the customer’s website (to look at an actual example from FormBackend).
If we want to list that in a pricing table, it could be something like: “Custom redirects” - let’s call that a description
. But we
also want to check in our code that the customer is allowed to do so? So we need to give it something to identify it by. The easy thing
is to just use some sort of string representation like "custom_redirects"
which we store in the identifier
column on the features
table. That way we can easily perform a check in the code, something like:
current_account.has_feature?(:custom_redirects)
But a feature can also be that you can create a certain number of something. Let’s say you can only create two forms on a given plan. We can introduce
an amount
column to our features table, and then have a check like:
current_account.amount_for(:forms)
Where forms
in this example is the value of the identifier
. If :forms
is not a feature on the given plan, we can just have that method
return 0. It would then be easy to have a simple check similar to this:
if current_account.amount_for(:forms) > current_account.forms.count
link_to "Create new form", new_form_path
end
We now have a very basic requirement done when it comes to checking what a customer is allowed to do on a given plan
Typically you have access to more and more as you move your way up the plan hierarchy.
Plan A might give you access to 2 forms, custom redirects - whereas Plan B gives you access to 5 forms, custom redirects and custom email templates.
Maybe our database records looked like the following, where we’d duplicate all the features for each plan:
<Plan id: 1, stripe_id: "PLAN_A", name: "Plan A">
<Feature id: 1, plan_id: 1, identifier: "forms" amount: 2>
<Feature id: 2, plan_id: 1, identifier: "custom_redirects" amount: nil>
<Plan id: 2, stripe_id: "PLAN_B", name: "Plan B">
<Feature id: 3, plan_id: 2, identifier: "forms" amount: 5>
<Feature id: 4, plan_id: 2, identifier: "custom_redirects" amount: nil>
<Feature id: 5, plan_id: 2, identifier: "custom_email_templates" amount: nil>
This definitely works. But we’d have to duplicate our features. If we ever added a new feature to “Plan A” then we’d have to create that same feature and add it to “Plan B” as well. Lot’s of extra work and a big chance of making a mistake and forgetting to add the feature to the other plans.
Feature inheritance
Given what we just walked through in the section above, it seems like the best way would be if we could inherit features from plans prior to the plan your on. Meaning you wouldn’t have to add a feature to “Plan B” that already exists on “Plan A”. Only if there’s changes to it. If we go from 2 forms to 5 forms, then we obviously need to add it to “Plan B”, but if it’s just “custom_redirects” which “Plan A” has access to, then there’s no need to add that to “Plan B”. It should just work!
So how do we accomplish this? Let’s add a previous_plan_id
to our plans
table.
So we now have something that looks like
Plans table
-------------
id - the unique identifier for the plan (auto-incremented)
stripe_id - this is the unique stripe product ID
identifier - the unique identifier for this feature. Example: "forms"
amount - the max number for this feature
previous_plan_id - a reference to the plan that comes before it
Having this, it means we can change the records we looked at in the previous section to this:
<Plan id: 1, stripe_id: "PLAN_A", name: "Plan A">
<Feature id: 1, plan_id: 1, identifier: "forms" amount: 2>
<Feature id: 2, plan_id: 1, identifier: "custom_redirects" amount: nil>
<Plan id: 2, stripe_id: "PLAN_B", name: "Plan B">
<Feature id: 3, plan_id: 2, identifier: "forms" amount: 5>
<Feature id: 5, plan_id: 2, identifier: "custom_email_templates" amount: nil>
It doesn’t look like much since we just eliminated one duplicated feature - but believe me, it adds up quick!
Time to implement the has_feature? method
Let’s go ahead and take a look at what our has_feature?(identifier)
method could look like
class Plan < ApplicationRecord
...
def has_feature?(identifier)
features.find_by(identifier: identifier)&.present?
end
end
This solves the simple problem without inheritance. But let’s look at solving the feature inheritance problem. Let’s extract the find feature logic to it’s own method.
class Plan < ApplicationRecord
...
def has_feature?(identifier)
find_feature(identifier)&.present?
end
def find_feature(identifier)
features.find_by(identifier: identifier)
end
end
We then want to check if there’s a feature on the current plan that matches what we’re looking for and if that isn’t the case, we want to look at the previous plan if any.
class Plan < ApplicationRecord
...
def has_feature?(identifier)
find_feature(identifier)&.present?
end
def find_feature(identifier)
feature = features.find_by(identifier: identifier)
return feature if feature
previous_plan.find_feature(identifier) if previous_plan
end
end