How we manage plans & features in our SaaS app

How do you deal with what a user can do on their account in a SaaS app? Can Jane on the "Starter" plan create another widget when she is near the limit of her plan? What if she's a trial user?

Turns out this is a mix of things

  • Feature toggling
  • Counting stuff™
  • Custom API middleware very specific to your situation

Like the topic of our last post in this series on creating a basic SaaS data model there is a lack of clear examples on how to deal with this super common problem.

Here is how we do it at Checkly with our Node.js, Hapi.js backend. This will probably translate fine to other platforms.

🇷🇺 A Russian translation of this post can be found at Статья на русском доступна здесь

The problem

Let's make it as concrete a possible and, as the saying goes, a SaaS pricing page is worth a thousand words.

We have three plans with varying prices: Developer, Starter and Growth.
Different plans allow different volumes and different features.

In this example:

  • The API and browser checks are volume limited. The Developer plan gets 5, the Starter plan 15, the growth plan 40.
  • The Team members feature is either enabled or not, and when enabled also volume limited.
  • The CI/CD trigger feature is either enabled or not. No volume stuff going on.

Not visible in the pricing page is what happens during a trial. During our 14 day trial we do not give trial users an SSL secured public dashboard. Due to technical and abuse reasons this only kicks in when you become a paying customer.

Also, remember that paying customers might have issues with their credit card, or simply stop paying for unknown reasons. We need to catch this, but we also want to err on the side of caution to not piss off customers just having some banking issue.

Let's boil it down to four categories of "things we have to somehow enforce and keep track of" in our SaaS app.

  1. Trial vs. Non-trial: Still kicking the tires or an honored member of our  little club?
  2. Paying vs. Lapsing: You used to pay us, but not anymore...
  3. Plan based feature toggles: Does your plan allow you to access this feature?
  4. Plan based volume limits: Does your plan allow you to create more of these things?

Trial vs. Non-trial

Every user that signs up is automatically assigned a trial plan. The data model is as shown below. Check this earlier post for more details.

accounts & plans data model

Checking this is straightforward, just do your language's variation of:

if (account.plan.name === "trial") {
	// do trial things
}

Being in a trial or not is a pretty binary and boolean friendly thing. Just make sure you switch the user to some other plan when he/she starts paying. Which brings us to...

Paying vs. Lapsing

Should be easy, right? Someone signs up for a paid plan and you flip a flag from paying = false to paying = true.

But what does "paying" actually mean? And what if they stop paying?

At Checkly, "paying" means your account record in our Postgres database has a stripe_subscription_id that is not NULL and a plan_expiry date that is in the future. In Javascript code:

const paying = account.stripe_subscription_id != null 
&& account.plan_expiry > Date.now()

Both fields are set when a Stripe webhook comes in that signals a successful payment for a subscription. This automatically tracks lapsing payments and subscription cancelations. No extra code to update an arbitrary "paying" field.

Takeaway: "paying" is not a boolean you update explicitly. It's a computed property depending on a bunch of fields. Take into account what a paying subscriber / account holder means in your specific context. If this is a monthly / yearly SaaS thing, you probably have more than one field of data to check.

Plan based feature toggles

To check what features a user can access based on their plan we store a set of string constants for each account in a field called features.  This builds on a base layer of features available to every subscriber. An empty list of features means you have the base plan. In code:

const features = ["CI_CD_TRIGGERS", "SOME_OTHER_FEATURE"]

This set of features lives as an array field on each account record a user is linked with. Furthermore, this field is made available to the backend and frontend, of course only writable by the backend. No updating your own features!

This field gets populated or updated on only two occasions:

  1. A user signs up for a trial. We populate the features field with trial features.
  2. A user upgrades to a paid account. We update the features field with features as they are in the corresponding plan.

We don't have a fancy interface for managing these feature toggles. This is not some experimentation or dark launching framework.

Checkly is a Vue.js single page app backed by a Hapi.js API backend. But this works probably on any SPA or non-SPA based system.

Here's what our route to controller mapping looks like.

const a = require('../../models/defaults/access-rights')
const f = require('../../models/defaults/features')

  {
    method: 'POST',
    path: '/accounts/triggers/{checkId}',
    config: {
      plugins: {
        policies: [hasAccess([a.OWNER, a.ADMIN]), hasFeature(f.TRIGGERS)]
      },
      handler: TriggerController.createTrigger
    }
  },

There are two interesting bits here.

  • The hasAccess function that checks for user access rights.
  • The hasFeature function that checks for features.

Both functions are enabled by the mr. Horse plugin, allowing for policies to be attached to any API route. You can also see we import the canonical list of access rights and features from a central list of defaults values.

What actually happens in the hasAccess and hasFeature functions depends heavily on what language/framework you are using.

Here are the shortened code versions of how we do it for the access rights and features. Notice they both return functions that the http router injects in the the http request cycle.

const hasAccess = function (accessRights) {

  // Define a function to check access based on request data.
  // in a previous authentication step, the account data was fetched
  // from the database.
  
  const hasSpecificAccess = function (request, reply, next) {
    if (accessRights.includes(access)) {
      next(null, true)
    } else {
      next(null, false)
    }
  }
  return hasSpecificAccess
}

Checking features...

const hasFeature = function (feature) {
  const hasSpecificFeature = function (request, reply, next) {
  
    // match if the feature is enabled

    return features && features.includes(feature) 
      ? next(null, true) 
      : next(null, false)
  }
  return hasSpecificFeature
}

Plan based volume limits

Checking plan features is pretty neatly handled by a fairly generic way of asserting if a thing is either "on" or "off".

Checking volumes is a bit different. Why is it different? It is different because we need to include the state of specific resources we are offering our customers, not just flags on the account record.

This means you have to actively poll your database and count stuff on each request. Yes, you can cache a bit and being off by one might not be the end of the world.

In the pricing page example above you can see Checkly offers 5 API checks for one plan and 15 for the other. This is how we assert this volume limit in our backend API

function getVolumeLimits (accountId, delta) {
  const checksCountQuery = Checks.query().where({ accountId }).count()
  const accountLimitsQuery = Account.query().findOne({ accountId })

  return Promise.all([checksCountQuery, accountLimitsQuery])
    .then(res => {
      const count = res[0].count
      const { maxChecks } = res[1]
      const newTotal = parseInt(count) + delta
      return newTotal <= maxChecks
    })
}
  1. This function is executed after basic authorization, but before any actual work is done.
  2. We fetch the current amount checks and the plan limit of checks for the current account concurrently. This is a very Javascript Promise.all statement.
  3. We compare the current amount to the new total amount. In our specific case, a user can create multiple checks at once, hence the delta argument. In this example it is 1 but in real life it can be any number above 0. We need to check if the total amount of new "things to be created" fits into the plan.
  4. In the end, we return if the newTotal is less or equal to the maxChecks, our plan limit.

Asserting users are within their plan limits on the backend is really important for all kinds of reasons, but how are we going to do "be nice about it" on the frontend, specifically in an SPA type setup? We don't want to have the situation where a user is happily creating a new thing, hits submit and is then presented with a "you are over your plan limits"-message.

What about the rest?

What about role based access control?
How the hell do you handle this stuff on the front end?

Yes, all very important topics. They all weave into the stuff discussed above so we'll dive into it in the next blog post. Call to action => ✨ Subscribe now ✨


banner image: Unsen, "New Invention: Picture of the Interior Works of a German Battleship",  Original ca. 1874. source

checkly

Sign up for more SaaS stories. No fluff, all long form content. 🍍

Show Comments