Flipper originally started with one price per month and per seat–the same price for every customer and seat. On one hand, it helped ensure that as customers grew and used more resources, Flipper would remain profitable, but we don’t love paying per-seat pricing in most cases.
For all but the largest businesses–where paying per-seat can be a rounding error–per-seat pricing sucks for customers.
So why ask others to pay per seat if it doesn’t feel like the best customer experience for Flipper Cloud?
That meant offering fixed-price monthly and yearly plans, but since the entire change was predicated on providing a better billing experience for customers, we weren’t going to force anybody to change plans–even if they’re currently using one of our free plans.
In order to do that, however, we’d need to be able to support multiple sets of pricing and entitlements.
Proliferation of Conditionals
With both per-seat and fixed-price subscription options (as well as our forthcoming Flipper Pro Gem), we’ll invariably be juggling a wide variety of conditionals, entitlements, and permissions.11And of course, all of this is happening behind a feature flag. So that’s one more variation we need to cover with tests. So we started refactoring with a vision for supporting legacy pricing in parallel with our new approach.
In a lot of ways, building Flipper Cloud to support multiple pricing options is over-engineering, but in terms of fulfilling the agreement to deliver service to people based on the price they agreed to when they signed up, it’s the only choice we have.
We could provide a few months at their current price or even take more heavy-handed tactics, but those approaches are for the companies purely interested in increasing revenue overnight. That’s just not us.
Entitlements inevitably dovetail with other layers of permissions unrelated to the customer’s chosen (or implicit) plan.
John has a post going into more of our reasoning behind designing the pricing from the customer experience outward, so I won’t get into those details here. This post is all about data modeling for billing, pricing, and entitlements in a way that meant we wouldn’t force any existing customers–paid or free–to change their plan just because we have new plans.
How do we want the internals to work?
At this point I’ve worked extensively with about ten different SaaS billing systems in one form or fashion since 2007. Over that time, I’ve continuously learned and optimized the data model and interfaces around billing and pricing. So when we started designing updated pricing for Flipper Cloud, I had a pretty well-developed vision for how I hoped to get it working.
We also wanted to be able to create a dynamic pricing page that reflected the relevant pricing options depending on a variety of factors like feature flags and an organization’s current plan/pricing if they are on a legacy pricing version. And since plans with different billing intervals (i.e. monthly vs. yearly) still share the same set of entitlements, we wanted to separate pricing from entitlements. There’s a lot more nuance to some of this, so we’ll just dive in.
Pricing Semantics & Classes
Let’s look at some goals through the lens of interface-driven development. Instead of starting out by designing the data model, we thought about how we’d like to interact with it at a programmatic level. (Figure 1)
# Access system-level pricing informationPricing.v0Pricing.v1Pricing.current # => v1Pricing.v0.current? # => falsePricing.v1.current? # => truePricing.v0.legacy? # => truePricing.v1.legacy? # => falsePricing.versions # => [v0, v1]# Determine the Pricing available to the accountaccount.pricing.version # => Pricing.v1account.pricing.current? # => account.pricing.version == Pricing.currentaccount.pricing.plans # => [Plan.freemium, Plan.basic, Plan.premium] A system-level Pricing class provides the larger context about current and legacy pricing options while supporting the ability for individual accounts to be on any version of pricing so legacy customers are never forced into new pricing.
With these classes and methods, we could let the application be largely ignorant of which version of pricing it was dealing with at any given time. In cases where customers are on legacy pricing, we can use simple comparisons to let them know when a new option is available. (Figure 2)
By designing a system that supports multiple concurrent versions of pricing, we can offer a dynamic feature comparison table that can let existing customers compare their current plan to new options so they can decide if they’d like to switch or not.
↩︎As a customer, it sucks to have pricing changes forced upon you, but it also sucks to have a plethora of new choices and no easy way to compare the options. So we built Flipper’s pricing in a way that customers can compare their current pricing across versions to make any transition as painless as possible.
Entitlements Semantics & Classes
Similar to pricing, we created a system-level approach to entitlements for things like simple on/off features and numeric limits. Then, those pre-determined sets of entitlements could be paired with any type of pricing without creating endless sets of virtually identical entitlements. That also simplified the process of testing entitlements because we could rely on a few baselines, test them exhaustively, and then extend those as needed. (Figure 3)
# Shared entitlements tiersEntitlements.freeEntitlements.basicEntitlements.premium# Overriding SpecificsEntitlements.free.override |e| e.analytics = true e.seats = 30end# Check boolean entitlementsaccount.gets?(:analytics)# Access numeric entitlement limitsaccount.limits(:seats) With a system-level Entitlements class fully extricated from pricing or plans, we can maintain multiple sets of entitlements where different sets can build on and override other sets. Ultimately, this reduces the amount of testing and consolidates the relevant tests for each tier.
There’s a non-trivial amount of code and logic below the surface with both the system-level pricing/entitlements as well as the account-level pricing/entitlements, but much of it is contextual to Flipper Cloud. I’ll share some of it below where it’s general enough to be useful, but otherwise, I’ll primarily focus on the concepts rather than the implementation.
We’ll go ahead and look at where we ended up, and then we’ll explore how and why we ended up where we did. In the simplest terms, we separated entitlements from pricing based on years of experience juggling pricing and entitlements on projects. (Figure 4)
The separation of entitlements and pricing led us to flexible data model where accounts could have pricing and entitlements independent of the current set of system equivalents. Concepts like seats, trial, and billing could all be compartmentalized as well.
Separating Plans and Entitlements
Over the years, the most common pattern I’ve seen involves putting a whole lot of data into the Plan model (or equivalent). That table usually includes the basics like plan name, price, interval, and sometimes a relative level to help differentiate between downgrades and upgrades.22With monthly and yearly plans, it’s not enough to recognize upgrades based only on the cost of the plan since yearly plans will be inherently more expensive than their monthly equivalents. In addition to those attributes, the plan records often include entitlement data like feature_one, user_limit, and similar values. As the system expands, the entitlement-related columns proliferate quickly. (Figure 5)
This can, and does, work for plenty of basic cases. However, as new features are added and the entitlement columns proliferate, the Plan model experiences significant churn. In cases where a product may be overdue for a price increase, we’d likely duplicate the plan row and only change the price column. Do that enough with enough plan options, and things get messy fast. Or, if a plan has a monthly and yearly option, duplicating the entitlements creates similar problems.
Adding entitlements onto the plans table means that the table will grow indefinitely in ways where similar plans have too much overlap while tightly coupling entitlements to a plan–and possibly a subscription.
↩︎All of this creates numerous opportunities for entitlements to be incorrect. Additionally, when we consider the fact that entitlements are only the account-level portion of whether or not a given user can do a given thing, we start to see how user permissions and/or feature flags factor into the equation alongside entitlements.
Policies Don’t Care About Pricing
In that context, tying those entitlements closely to plans and pricing doesn’t hold up. Entitlements inevitably dovetail with other layers of permissions unrelated to the customer’s chosen (or implicit) plan.
All of these factors can be rolled up to be accessed via policies. (Figure 6) The policies themselves don’t care about the pricing. They only want to know what is and isn’t allowed.
With entitlements extracted into their own concept, they can more easily be tested in isolation without having to create a significant amount of brittle setup code. This also makes it more straightforward to build policies that depend on a variety of permissions-related concepts from the application.
↩︎Free Customers Don’t have Subscriptions or Plans
Then there’s the consideration that customers on a free plan, don’t have or need a subscription and plan record for the entitlements. We could create a $0 subscription, but in my experience, that approach leads to clunky interactions with the subscription model.
If free customers don’t have subscriptions or plans, they won’t have a place to store and look up their entitlements. That leads to the fact that we need a way to handle entitlements for customers that don’t explicitly have active subscriptions with plans. (Figure 7) Instead, we need a way for them to have an implicit plan/entitlements.
Since there’s not much value in our context for creating a ‘free’ subscription, free customers can still have a relationship with a given set of entitlements without needing the intermediary plan or subscription.
↩︎All of these considerations support separating the concepts of payment terms from capabilities. We ended up with a system-level Pricing class along with a sibling Entitlements class. That way, we can have different versions of pricing while having different versions of entitlements at the same time. Then if multiple prices share the same set of entitlements, we connect the plans to the relevant entitlements. We’ll explore entitlements shortly, but we’ll start with pricing. A brief code sample may be better suited to illustrating how this all works. (Figure 8)
class Account < ApplicationRecord # ... # If they have an active subscription, use the associated entitlements. # ...otherwise, using the free entitlements from the organization's pricing. # ...and if all else fails, fall back to the current set of free entitlements. def entitlements active_subscription&.entitlements || pricing.entitlements.free || Entitlements.free end # ...end We can provide predictable entitlements to free customers even when they don’t have an active subscription because we can fall back to the free entitlements with their current pricing version or even fall back to the current system-level free entitlements for complete coverage.
↩︎Pricing Structure & Internals
Building such a robust and dedicated pricing framework for a new product is unlikely to be a pragmatic choice, and Flipper was no different. In order to find the right pricing, however, it can’t entirely be an afterthought.
For most teams, it’s unlikely that this level of planning is necessary, but in cases where designing the data model this way doesn’t create unnecessary friction, it ensures extensibility in the long-run.
Moreover, it’s not purely about raising/lowering prices. Sometimes, it’s about changing the structure of the pricing–like moving from seat-based to fixed-price or metered plans.
Pricing is a Powerful Lever
Pricing and entitlements, provide some of the most impactful levers a SaaS app can pull to find product-market fit. Lowering prices can make a product accessible to a wider audience. Raising prices can reduce the impact of rising operating costs. Changing the structure of the pricing can have similar impacts.
If it’s too difficult to experiment with pricing and entitlements, chances are higher that a product will stick with incorrect pricing far too long. If it’s easier to experiment, though, an application isn’t purely tied to legacy decisions that were made at a point in time where the team literally had the least information it would ever have.
Let Customers Decide
If a company has to raise prices due to increasing operating costs, it may be necessary to nudge/force customers into new pricing immediately for the health of the business, but that’s not our position.
We absolutely didn’t want to force anybody to change plans if they’re happy with their existing option. Since we built the system to support multiple pricing structures, we (somewhat) accidentally built a way support multiple pricing versions in parallel.
Feature Flags Streamline Rolling Out New Pricing
Pricing changes can be burdensome for everyone if they’re too tightly coupled to how an application works. With feature flags, we’re able to progressively share the new pricing with customers, get feedback, and improve any spots where we might have overlooked something.
And, if you’re using feature flags, this kind of modeling of pricing and entitlements can work nicely with those. For example, since Flipper does feature flags, we already planned on building our new pricing behind a flag. That meant the entire billing system could toggle between pricing versions with relative ease while any customer could continue on a different version of pricing even if that version wasn’t the current pricing available to new customers.
…we’d much prefer to let customers make their own decisions rather than drag them into new versions of pricing or entitlements without them having a say.
That also meant that we’d have less trouble exploring new pricing options in the future while ensuring that any existing customers remain unaffected unless they prefer the new options.
However, it also means we’ll be maintaining seat-based pricing alongside fixed-price monthly or yearly plans indefinitely. Or, if the change goes poorly and customers don’t like the new options, we’ll be able to return to seat-based pricing with minimal effort.
To achieve this, we created a system-level Pricing class to encapsulate the various versions of pricing, and within that class, we created a Pricing::Version class for the data about each pricing option.
Pricing Versions
With the versions of pricing, each one is numbered, receives a name for convenient internal reference, has a summary to clarify the primary focus of that pricing version, and a start date that determines when the new pricing becomes available to everyone. (The end date is implicit by using the start date of the next version.) And finally, each version needs to know which plans are available to the customers on that version of the pricing.
I’ve written some pseudo-code with extensive comments instead of relying purely on prose to explain the structure of the pricing versions. (Figure 9)
class Pricing class Version # These let us have a pricing version that's a little more robust # than just a Struct but without having to be database-driven. include ActiveModel::API include ActiveModel::Attributes include Comparable attribute :number, :integer attribute :name, :string attribute :summary, :string attribute :start_date, :datetime # ActiveModel attributes don't support arrays, so this is # a standard read-only attribute attr_reader :plans def initialize(attributes={}) @plans ||= Array(attributes.delete(:plans)) super end # Is this a legacy version of pricing no longer available to # new customers? def legacy? return false if start_date.blank? end_date.present? && end_date < Time.zone.now end # Is this the currently active version of pricing that new # customers are offered on the pricing page. def active? return false if start_date.blank? now = Time.zone.now start_date <= now && (end_date.nil? || end_date >= now) end # Is this an as-yet unpublished version of pricing def future? start_date.blank? || Time.zone.now < start_date end # Get the end date based on the start date of the next # version of pricing in the Versions array def end_date return nil unless next_version.present? && next_version.start_date.present? next_version.start_date end # To simplify comparisons of pricing versions in order # to know an org's pricing situation relative to the current # pricing. # ex. org.pricing.version < Pricing.current def <=>(other) number <=> other.number end # We invariably need to be able to discern when pricing # versions are identical def ==(other) number == other.number end alias_method :eql?, :== # Convenient accessor to access the customers on a given # version of pricing. def accounts Account.where(pricing_version: number) end private def first? self == Pricing.versions.first end def last? self == Pricing.versions.last end def next_version return nil if last? Pricing.versions[number + 1] end def previous_version return nil if first? Pricing.versions[number - 1] end endend With an explicit Pricing::Version class, we’re able to perform comparisons and switch between pricing versions for existing customers while still maintaining a definitive definition of the ‘current’ public pricing.
With versions mapped out like this, we can move between them with relative ease. Every Flipper Organization is aware of its own current pricing version at the time of sign up, and they keep that version unless they change plans. i.e. Cancellations, failed payments, or changing to one of the new options. Now, let’s look at how the various pricing versions roll up into the larger pricing considerations.
Pricing & Account Pricing
With a top-level Pricing class (Figure 10)
, we can keep an array of pricing versions. Then we can use Pricing.current for convenient access to the currently public pricing, and Pricing.current.plans provides the list of currently available subscription plans for customers. Similarly, account.pricing.plans makes it easy to substitute an account’s legacy pricing where relevant. Everything else is built to be flexible enough to be indifferent about which version of pricing is the current version.
class Pricing def self.versions VERSIONS end def self.current # Returns the generally-available current pricing end private VERSIONS = [ Version.new( number: 0, name: "Launch", summary: "Launch Pricing", plans: %i[ free basic premium ], start_date: Date.new(2000, 1, 1), ), Version.new( number: 1, name: "Current", summary: "Add Yearly Options", plans: %i[ free basic_monthly basic_yearly premium_monthly premium_yearly ], start_date: Date.new(2001, 1, 1), ) ].freeze end Each pricing version can be defined so that we have snapshots of every version of pricing since the beginning and customers can be connected to any version at any time.
↩︎The Magic of Encapsulation
In addition to the top-level Pricing class, we used the ActiveRecord::AssociatedObject gem by Kasper Timm Hansen to encapsulate various aspects of the billing and entitlements logic for individual accounts. While it’s a great gem, it’s not critical to this approach. It merely cleans up and compartmentalizes related bits of logic instead of defining them all within Account. And since they’re defined as constants rather than database records, it’s made this approach much more enjoyable to work with. (Figure 11)
# app/models/account.rbclass Account < ApplicationRecord has_object :pricingend# app/models/account/pricing.rbclass Account class Pricing < ActiveRecord::AssociatedObject # account.pricing.current def current Pricing.versions[account.pricing_version] end # account.pricing.plans def plans current.plans end endend The ActiveRecord::AssociatedObject gem by Kasper Timm Hansen has been a wonderful tool for organizing and compartmentalizing the various elements that work together for the various elements of billing, plans, entitlements, and trials.
Entitlements Organization
Entitlements follow the same playbook as pricing, but instead of using an array for the full list of historic entitlements, we use a hash where the key represents the short name for a given set of entitlements. The only other difference is that we refer to entitlement levels as “tiers” rather than “versions.”
Entitlement Tiers
Entitlement tiers are minimal compared to pricing versions. Whereas pricing versions can be comparable as discrete snapshots, entitlements vary more and make it challenging to define when one tier is better or worse than a different tier. For each tier, we have a list of features where each tier can have different values for each of the features.
If we look at an entitlements tier, there’s not much there because the critical logic is handled within the set of features and their configuration for a given tier. Then we can define different types of entitlements with convenient interfaces. (Figure 12)
class Entitlements # Tiers provide a way to have a "freemium" set of entitlements # along with a "basic" or "premium" set. Then those sets of # entitlements can be connected to a given plan or be used as # the default set of entitlements for freemium accounts. class Tier # ... endendclass Entitlements # Toggles represent boolean entitlements where a given # feature is either enabled or disabled (true/false) class Toggle # ... end endclass Entitlements # Limits represent numeric entitlements where different # tiers can support larger or smaller quantities class Limit # ... endend Similar to Pricing::Version, the Entitlements::Tier class provides a way to define snapshots of available entitlements that can then be associated with plans or directly with customers on free plans. Additionally, specific types of entitlements (i.e. boolean toggles, integer limits, etc.) can provide relevant interfaces for checking those entitlements.
Within the collection of entitlement features, we store a set of features and the relevant values for that tier. This way, we can start from a default set of features locked down to the bare minimum, and then each tier we create overrides those values to expand the set of entitlements for a given account. Alternatively, any tier can start from a pre-existing tier and only override the values that need to be different.
Entitlements & Account Entitlements
Our approach with entitlements focus more on encapsulating the concepts of entitlements and providing natural interfaces for feature checks. I’ve glossed over the underlying storage mechanism for defining the features because any method can work, but hopefully the interfaces and focus on exposing the entitlements via accounts still makes sense.
Similarly, while the code below shows a couple of example ideas, the specific methods and syntactic sugar can be all your own. Most of the benefits stem from having a clear place to put logic rather than the details of that logic.
class Account # ... # Using ActiveRecord::AssociatedObject has_object :toggles, :limits # account.gets?(:analytics) def gets?(toggle) toggles.enabled?(toggle) end # account.over?(seats: seats.billable.count) def over?(**kwargs) limit, amount = kwargs.to_a.first limits[limit].exceeded?(amount) end class Toggles < ActiveRecord::AssociatedObject # ... end class Limits < ActiveRecord::AssociatedObject # ... endend Instead of a generic entitlements off an account, specialized groups can be created to organize toggles or numeric limits which are likely to have different approaches for checking the entitlement.
All of this helps us manage multiple versions of pricing and entitlements without forcing any customers onto new plans that they might otherwise not like. It’s trade-offs all the way down, but we’d much prefer to let customers make their own decisions rather than drag them into new versions of pricing or entitlements without them having a say.
But why?
Years ago, I would have said that this approach is idealistic and far too customer-centric, but with the right design and implementation details, it feels like the right approach for us and for our customers.
We’ve all been on the losing end of company-centric pricing changes too many times, and while any company is entitled to change their prices on a whim, it’s not the way we want to do things. So instead of changing prices and telling customers to shove it, we would rather build and model Flipper to deliver on our earlier promises while still letting us move forward and learn what’s best for both Flipper and our customers.