Tip
š Ship your next Rails app 10x faster! I've built RailsFast, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks. Go check it out!
pricing_plans allows you to enforce pricing plan limits with one-liners that read like plain English. Avoid scattering and entangling pricing logic everywhere in your Rails SaaS.
For example, this is how you define pricing plans and their entitlements:
plan :pro do
allows :api_access # Features: blocked by default unless explicitly allowed
limits :projects, to: 5 # Limits: 0 by default unless a limit is set explicitly
endPlans are secure by default: features are disabled and limits are set to 0 unless explicitly configured.
You can then gate features in your controllers:
before_action :enforce_api_access!, only: [:create]Do one-liner checks to hide / show conditional UI:
<% if current_user.within_plan_limits?(:projects) %>
...
<% end %>
Or check limits and feature access anywhere in your app:
@user.plan_allows_api_access? # => true / false
@user.projects_remaining # => 2pricing_plans is your single source of truth for pricing plans, so you can use it to build pricing pages and paywalls too.
The gem works standalone, and it also plugs nicely into popular gems: it works seamlessly out of the box if you're already using pay or usage_credits. More info here.
Add this to your Gemfile:
gem "pricing_plans"Then install the gem:
bundle installAfter that, generate and run the required migration:
rails g pricing_plans:install
rails db:migrateThis will also create a config/initializers/pricing_plans.rb file where you need to define your pricing plans.
Then, just add the model mixin to the plan owner, that is: the actual model on which limits should be enforced (User, Organization, etc.):
class User < ApplicationRecord
include PricingPlans::PlanOwner
endThis mixin will automatically give your plan owner model the model helpers and methods you can use to consistently check and enforce limits:
class User < ApplicationRecord
include PricingPlans::PlanOwner
has_many :projects, limited_by_pricing_plans: { error_after_limit: "Too many projects for your plan!" }, dependent: :destroy
endYou also get controller helpers:
before_action { gate_feature!(:api_access) }
# or with syntactic sugar:
before_action :enforce_api_access!And you also get a lot of view helpers and methods to check limits in your views for conditional UI, and to build usage meters, usage warnings, and a handful of other useful UI components.
You can also display upgrade alerts to prompt users into upgrading to the next plan when they're near their plan limits:
You can attach arbitrary plan metadata for UI/presentation needs (icons, colors, badges) directly in the initializer:
plan :hobby do
metadata icon: "rocket", color: "bg-red-500"
end
plan.metadata[:icon] # => "rocket"You can also grandfather users into old plans (hidden to other users), assign plans manually without requiring a payment (for testing, gifts, or employees), and much more!
Important
This gem has extensive docs. Please š read the docs here š
pricing_plans handles pricing plan entitlements; that is: what a user can and can't access based on their current SaaS plan.
Some other features you may like:
- Grace periods (hard & soft caps for limits)
- Customizable downgrade behavior for overage handling
- Row-level locks to prevent race conditions on quota enforcement
Here's what pricing_plans does not handle:
- Payment processing / billing (that's
payor Stripe's responsibility) - Price definition / currency handling (that's Stripe / payment processor)
- Usage credits / metered usage (that's
usage_credits's responsibility) - Feature flags for A/B testing or staged rollouts (that's
flipper) - User roles, authorization, or per-user permissions (that's
cancancanorpundit)
If you've ever had to implement pricing plan limits, you probably found yourself writing code like this everywhere in your app:
if user_signed_in? && current_user.payment_processor&.subscription&.processor_plan == "pro" && current_user.projects.count <= 5
# ...
elsif user_signed_in? && current_user.payment_processor&.subscription&.processor_plan == "premium" && current_user.projects.count <= 10
# ...
endYou end up duplicating this kind of snippet for every plan and feature, and for every view and controller.
This code is brittle, tends to be full of magical numbers and nested convoluted logic; and plan enforcement tends to be scattered across the entire codebase. If you change something in your pricing table, it's highly likely you'll have to change the same magical number or logic in many different places, leading to bugs, inconsistencies, customer support tickets, and maintenance hell.
Enforcing pricing plan limits in code (through entitlements, usage quotas, and feature gating) is tedious and painful plumbing. Every SaaS needs to check whether users can perform an action based on the plan they're currently subscribed to, but it often leads to brittle, scattered, unmaintainable pricing logic that gets entangled with core application code, opening gaps for under-enforcement and leaving money on the table.
Integrating payment processing (Stripe, pay, etc.) is relatively straightforward, but enforcing actual plan limits (ensure users only get the features and usage their tier allows) is a whole different task. It's the kind of plumbing no one wants to do. Founders often put their focus on capturing the payment, and then default to a "poor man's" implementation of per-plan entitlements. Maintaining these in-house DIY solutions is a huge time sink, and engineers often can't keep up with constant pricing or packaging changes.
pricing_plans aims to offer a centralized, single-source-of-truth way of defining & handling pricing plans, so you can enforce plan limits with reusable helpers that read like plain English.
The pricing_plans gem uses four models: Assignment, EnforcementState, Usage, and FeatureGrant. Why are they needed?
PricingPlans::Assignmentstores explicit pricing plan overrides independently of the billing system. This is useful for gifts, employee access, demos, support interventions, and grandfathered customers.- What: A
plan_keyand a required provenance label such as"admin","customer_success_gift", or"legacy_import". There can be only one override per plan owner. - How it's used:
PlanResolverchecks explicit override ā Pay subscription ā configured default. Every persisted override takes precedence over subscription-based plans, including an override whose key happens to equal the configured default. - Intention-revealing API: call
plan_owner.override_pricing_plan!(:pro, source: "admin")to create or update an override, andplan_owner.clear_pricing_plan_override!to resume normal subscription/default resolution. - Provenance helpers:
pricing_plan_overridden?,pricing_plan_override, andpricing_plan_override_sourceexpose the override directly.current_pricing_plan_resolutionpreserves both entitlement and billing context when an override and a subscription coexist.
- What: A
An ordinary free/default account needs no assignment row:
# Intentional exception: pin this organization to Pro independently of billing.
organization.override_pricing_plan!(:pro, source: "customer_success_gift")
# Resume automatic Pay subscription / configured default resolution.
organization.clear_pricing_plan_override!The old assign_pricing_plan! and remove_pricing_plan! names are deprecated because they hide the override semantics. See Check and explicitly override plans for migration and strict-mode guidance.
-
PricingPlans::EnforcementStatetracks per-plan_owner per-limit enforcement state for persistent caps and per-period allowances (grace/warnings/block state) in a race-safe way.- What:
exceeded_at,blocked_at, last warning info, and a small JSONdatacolumn where we persist plan-derived parameters like grace period seconds. - How itās used: When you exceed a limit, we upsert/read this row under row-level locking to start grace, compute when it ends, flip to blocked, and to ensure idempotent event emission (
on_warning,on_grace_start,on_block).
- What:
-
PricingPlans::Usagetracks per-period allowances (e.g., ā3 projects per monthā). Persistent caps donāt need a table because they are live counts.- What:
period_start,period_end, and a monotonicusedcounter with a last-used timestamp. - How itās used: On create of the metered model, we increment or upsert the usage for the current window (based on
PeriodCalculator). Reads powerremaining,percent_used, and warning thresholds.
- What:
-
PricingPlans::FeatureGrantstores optional per-owner feature exceptions independently of the current plan.- What: A feature key, provenance source, optional note/expiry, and revocation timestamp.
- How itās used: Active grants participate in
plan_allows?; revocation retains the row so the grant/revocation lifecycle remains inspectable. Apps upgrading from before 0.6.0 add this table withrails generate pricing_plans:grants && rails db:migrate.
Enforcing pricing plans is one of those boring plumbing problems that look easy from a distance but get complex when you try to engineer them for production usage. The poor man's implementation of nested ifs shown in the example above only get you so far, you soon start finding edge cases to consider. Here's some of what we've covered in this gem:
-
Safe under load: we use row locks and retries when setting grace/blocked/warning state, and we avoid firing the same event twice. See grace_manager.rb.
-
Self-healing state: when usage drops below the limit (e.g., user deletes resources, upgrades plan, or reduces usage), stale exceeded/blocked flags are automatically cleared. Methods like
grace_active?andshould_block?will clear outdated enforcement state as a side effect. This prevents users from remaining incorrectly flagged after remediation. -
Accurate counting: persistent limits count live current rows (using
COUNT(*), make sure to index your foreign keys to make it fast at scale); perāperiod limits record usage for the current window only. You can filter what counts withcount_scope(Symbol/Hash/Proc/Array), and plan settings override model defaults. See limitable.rb and limit_checker.rb. -
Clear rules: default is to block when you hit the cap; grace periods are optāin. In status/UI, 0 of 0 isnāt shown as blocked. See plan.rb, grace_manager.rb, and view_helpers.rb.
-
Semantic enforcement: for
grace_then_block, grace periods start when usage goes over the limit (e.g., 6/5), not when it reaches the limit (5/5). This allows users to use their full allocation before grace begins. Forblock_usage, blocking occurs at or over the limit (e.g., at 5/5, the next creation is blocked). -
Simple controllers: oneāliners to guard actions, predictable redirect order (perācall ā perācontroller ā global ā pricing_path), and an optional central handler. See controller_guards.rb.
-
Billingāaware periods: supports billing cycle (when Pay is present), calendar month/week/day, custom time windows, and durations. See period_calculator.rb.
When a customer moves to a lower plan (via Stripe/Pay or an explicit override), the new planās limits start applying immediately. Existing resources are never autoādeleted by the gem; instead:
- Persistent caps (e.g.,
:projects, to: 3): We count live rows. If the account is now over the new cap, creations will be blocked (or put into grace/warn depending onafter_limit). Users must remediate by deleting/archiving until under cap. - Perāperiod allowances (e.g.,
:custom_models, to: 3, per: :month): The current windowās usage remains as is. Further creations in the same window respect the downgraded allowance andafter_limitpolicy. At the next window, the allowance resets.
Use OverageReporter to present a clear remediation UX before or after applying a downgrade:
report = PricingPlans::OverageReporter.report_with_message(org, :free)
if report.items.any?
flash[:alert] = report.message
# report.items -> [#<OverageItem limit_key:, kind: :persistent|:per_period, current_usage:, allowed:, overage:, grace_active:, grace_ends_at:>]
endExample human message:
- "Over target plan on: projects: 12 > 3 (reduce by 9), custom_models: 5 > 0 (reduce by 5). Grace active ā projects grace ends at 2025-01-06T12:00:00Z."
Notes:
- If you provide a
config.message_builder, itās used to customize copy for the:overage_reportcontext. - This reporter works regardless of whether any controller/model action has been hit; it reads live counts and current period usage.
Sooner or later you'll reprice: a feature that used to be on a cheap plan moves to a higher one. The customers who already pay you should keep what they signed up for ā and that should not require new columns, backfills, or rake tasks in your app. (Full guide: docs/07-repricing.md.)
Remove the feature from the plan's allows, and declare the grandfather right next to it:
plan :indie do
allows :api_access # :distribution moved to :starter on 2026-08-31
grandfather :distribution, subscribed_before: "2026-09-01"
endThat's the whole migration. Owners whose qualifying pricing relationship predates the cutoff keep the feature; everyone who arrives later doesn't. plan_allows?(:distribution) (and the plan_allows_distribution? sugar) just keep working everywhere ā the gate code in your app doesn't change at all. Your initializer stays the single, git-versioned record of what changed and when.
Semantics, precisely:
- Eligibility time is the older of the current manual assignment and a current subscription whose processor price maps to the same resolved plan. An old subscription on another plan cannot make a new override eligible.
- Pay keeps a subscription's original
created_atwhen its price is swapped; changing the plan key on an existing manual assignment likewise keeps that assignment row's age. This measures a continuous pricing relationship, not the exact date the current plan or price was selected. That is usually the desired grandfathering policy; use grants if you need a frozen, exact historical cohort. - Grandfathering rides that continuous relationship: cancel and re-subscribe, or clear and later recreate an assignment, and you re-enter at current pricing. For a promise that must survive anything, use a grant (below).
- Cutoffs accept a
Time,ActiveSupport::TimeWithZone,Date, orString; date-only values are read as midnight UTC. - Declaring
grandfatherfor a feature the plan stillallowsraises a configuration error ā one of the two lines is a mistake.
For individual exceptions ā comps, beta access, sales promises, support remediation, or a grandfather that must survive cancellation ā grant the feature to the owner directly:
org.grant_feature!(:distribution, source: "founder_comp", note: "conference friend")
org.grant_feature!(:sso, source: "sales", expires_at: 30.days.from_now)
org.plan_allows?(:sso) # => true (source-aware predicate below)
org.feature_entitlement_source(:sso) # => :plan | :grandfather | :grant | nil
org.feature_granted?(:sso) # => true (active grant row exists)
org.revoke_feature!(:sso, note: "eval over") # keeps the row, stamps revoked_at
org.feature_grants # retained grant/revocation historyGrants live in the pricing_plans_feature_grants table (created by the install generator; apps upgrading from < 0.6.0 add it with rails generate pricing_plans:grants && rails db:migrate). They attach to the owner, not the plan, so they survive plan changes and cancellations until expiry or revocation. Rows are never deleted by the API: revoking stamps revoked_at, preserving each grant/revocation lifecycle. Re-granting while a grant is active updates that row; it is not an event-by-event audit log of field edits.
Offer a customer a sample without changing billing or replacing an existing promise:
org.issue_feature_pass!(:distribution, source: "sales_evaluation",
expires_at: 3.months.from_now,
limits: { storage_bytes: 1.gigabyte }, usage_limit: 2.gigabytes)The complete feature pass guide covers capacity versus
cumulative consumption, atomic write enforcement, operator controls, upgrade
precedence, expiry, revocation, and the additive migration for existing apps.
Named limits require live measurements at the write boundary; see
with_feature_access! in the guide before enabling a bounded offer.
Some times you'll want to override plan limits / feature gating checks. A common use case is if you're responding to a webhook (like Stripe), you'll want to process the webhook correctly (bypassing the check) and maybe later handle the limit manually.
To do that, you can use require_plan_limit!. An example to proceed but mark downstream:
def webhook_create
result = require_plan_limit!(:projects, plan_owner: current_organization, allow_system_override: true)
# Your custom logic here.
# You could proceed to create; inspect result.grace?/warning? and result.metadata[:system_override]
Project.create!(metadata: { created_during_grace: result.grace? || result.warning?, system_override: result.metadata[:system_override] })
head :ok
endNote: model validations will still block creation even with allow_system_override -- it's just intended to bypass the block on controllers.
We use Minitest for testing. Run the test suite with:
bundle exec rake testAfter checking out the repo, run bin/setup to install dependencies. Then, run rake test to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install.
Bug reports and pull requests are welcome on GitHub at https://github.com/rameerez/pricing_plans. Our code of conduct is: just be nice and make your mom proud of what you do and post online.
The gem is available as open source under the terms of the MIT License.


