Subscription features for your SaaS

21 Oct 2023

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