This article was originally published on Build a SaaS with Rails from Rails Designer
When building a SaaS application, you’ll inevitably need to manage different subscription plans with varying features and limitations. A common approach is to hard-code plan features directly in your models:
PLANS = {
"starter" => { member_count: 1, workflows: 5, ai_enabled: false },
"pro" => { member_count: 10, workflows: 25, ai_enabled: true }
}
While this works initially, it quickly becomes rigid and difficult to maintain. What happens when you need to give a customer a custom deal? Or when support needs to temporarily increase someone’s limits? You’re stuck modifying code or creating one-off exceptions.
I’ve found a much more flexible approach over the past decade: give each user their own individual access configuration that can be easily modified. Let me walk you through how I implement this system.
The Access Model
First, I create a dedicated model to store each user’s access configuration using RailsVault:
# app/models/user/access.rb
class User::Access < RailsVault::Base
vault_attribute :member_count, :integer, default: Config::Plans.fallback.dig(:features, :member_count)
vault_attribute :enabled_workflow_count, :integer, default: Config::Plans.fallback.dig(:features, :enabled_workflow_count)
vault_attribute :enabled_endpoint_count, :integer, default: Config::Plans.fallback.dig(:features, :enabled_endpoint_count)
vault_attribute :total_steps_per_workflow_count, :integer, default: Config::Plans.fallback.dig(:features, :total_steps_per_workflow_count)
vault_attribute :total_monthly_run_count, :integer, default: Config::Plans.fallback.dig(:features, :total_monthly_run_count)
vault_attribute :ai_enabled, :boolean, default: Config::Plans.fallback.dig(:features, :ai_enabled)
end
Each attribute represents a specific feature or limitation in my application. I follow a personal convention of using _count
suffixes for numeric limits and _enabled
suffixes for boolean features (the booleans, can be called using a ?
predicate, e.g. ai_enabled?
).
The defaults come from your plan configuration (more on that below).
Adding Feature Access to Users
Next, I create a concern that handles the feature access logic:
# app/models/user/feature_access.rb
module User::FeatureAccess
extend ActiveSupport::Concern
included do
vault :access
end
def add_access(product_id)
access.update Config::Plans[product_id.to_sym][:features]
end
end
The add_access
method takes a Stripe product ID and applies the corresponding plan’s features to the user. You’d typically call this from your Stripe webhook handler when a subscription is created or updated. Of course this is not limited to Stripe.
Include this concern in your User model:
# app/models/user.rb
class User < ApplicationRecord
include FeatureAccess
# …
end
Plan Configuration
Using the configuration system from my previous article, I define all plans in a YAML file:
# config/configurations/plans.yml
shared:
fallback:
name: Free
features:
member_count: 1
enabled_workflow_count: 1
enabled_endpoint_count: 2
total_steps_per_workflow_count: 3
total_monthly_run_count: 100
ai_enabled: false
development:
starter_plan_id:
name: Starter
price_id: price_dev_starter
amount: 19
features:
member_count: 1
enabled_workflow_count: 5
enabled_endpoint_count: 10
total_steps_per_workflow_count: 5
total_monthly_run_count: 5_000
ai_enabled: false
pro_plan_id:
name: Pro
price_id: price_dev_pro
amount: 49
features:
member_count: 5
enabled_workflow_count: 25
enabled_endpoint_count: 50
total_steps_per_workflow_count: 15
total_monthly_run_count: 25_000
ai_enabled: true
production:
starter_plan_id:
name: Starter
price_id: price_prod_starter
amount: 19
features:
member_count: 1
enabled_workflow_count: 5
enabled_endpoint_count: 10
total_steps_per_workflow_count: 5
total_monthly_run_count: 5_000
ai_enabled: false
pro_plan_id:
name: Pro
price_id: price_prod_pro
amount: 49
features:
member_count: 5
enabled_workflow_count: 25
enabled_endpoint_count: 50
total_steps_per_workflow_count: 15
total_monthly_run_count: 25_000
ai_enabled: true
The fallback
section defines the default free tier that new users get. Each plan’s features
hash corresponds exactly to the vault attributes defined in the Access model. Notice how the feature names match between the plans configuration and the Access model attributes.
Usage
Now you can easily check user access throughout your application:
# Check limits
Current.user.access.member_count # => 5
Current.user.access.enabled_workflow_count # => 25
# Boolean checks with the ? suffix
Current.user.ai_enabled? # => true
# In controllers
def create
if Current.user.workflows.enabled.count >= Current.user.access.enabled_workflow_count
redirect_to upgrade_path, alert: "Upgrade to create more workflows"
return
end
# …
end
When handling Stripe webhooks, applying access is nice and clean:
# In your Stripe webhook handler
def checkout_session_completed(event)
session = event.data.object
user = User.find_by(stripe_customer_id: session.customer)
# Extract the product ID from the line items
product_id = session.line_items.data.first.price.product
user.add_access(product_id)
end
This system’s real strength becomes apparent when you need flexibility:
Custom deals: Easily give a customer extra features without code changes:
user.access.update(member_count: 100) # Custom deal for enterprise prospect
Support scenarios: Temporarily increase limits while investigating issues:
user.access.update(total_monthly_run_count: 100_000) # Temporary increase
A/B testing: Test different feature combinations:
# Give beta users early access to AI features
users.beta_enabled.each { it.access.update(ai_enabled: true) }
This approach has saved me many hours of custom development over the last decade and made customer support and sales much more pleasant. Instead of saying “that’s not possible with your current plan”, I can often solve problems by adjusting their individual access settings.
And there you have it. The examples in above code are simplified to focus the core of this feature. If you have more advanced use cases, I am happy to help.