Your PlanOwner class is the class on which plan limits are enforced.
It's usually the same class that gets charged for a subscription, the class that gets billed, the class that "owns" the plan, the class with the pay_customer if you're using Pay, etc. It's usually: User, Organization, Team, etc.
To define your PlanOwner class, just add the model mixin:
class User < ApplicationRecord
include PricingPlans::PlanOwner
endBy adding the PricingPlans::PlanOwner mixin to a model, you automatically get all the features described below.
You can include PricingPlans::PlanOwner in more than one model (say, User on the consumer side and Organization on the company side, each with their own pay_customer). Plan resolution is per-owner, so this mostly just works (#22):
- Give each side its own paid plans; a subscription resolves whichever plan matches its Stripe price, regardless of which model holds it.
- In controllers, point the gem at the right owner per side with a custom plan-owner method (see the controller helpers) returning
Current.userorCurrent.organizationas appropriate. - For pricing pages, tag plans with
metadata (e.g.meta audience: "users"vs"organizations") and filterPricingPlans.plansby it, so each side sees only its own ladder.
The one real constraint today: there is a single global default! plan (and a single highlighted!), shared by every owner model — so keep the default plan generic enough for both sides. First-class plan groups (per-owner-type defaults and highlights) are a candidate for a future release.
Now you can link any has_many relationships in this model to limits defined in your pricing_plans.rb
For example, if you defined a :projects limit like this:
plan :pro do
limits :projects, to: 5
endThen you can link the :projects limit to any has_many relationship on the PlanOwner model (User, in this example):
class User < ApplicationRecord
include PricingPlans::PlanOwner
has_many :projects, limited_by_pricing_plans: true
endThe :limited_by_pricing_plans part infers that the association name (:projects) is the same as the limit key you defined on pricing_plans.rb. If that's not the case, you can make the association explicit:
class User < ApplicationRecord
include PricingPlans::PlanOwner
has_many :custom_projects, limited_by_pricing_plans: { limit_key: :projects }
endIn general, you can omit the limit key when it can be inferred from the model (e.g., Project → :projects).
limited_by_pricing_plans plays nicely with every other ActiveRecord validation you may have in your relationship:
class User < ApplicationRecord
include PricingPlans::PlanOwner
has_many :projects, limited_by_pricing_plans: true, dependent: :destroy
endYou can also customize the validation error message by passing error_after_limit. This error message behaves like other ActiveRecord validation, and will get attached to the record upon failed creation:
class User < ApplicationRecord
include PricingPlans::PlanOwner
has_many :projects, limited_by_pricing_plans: { error_after_limit: "Too many projects!" }, dependent: :destroy
endThe PlanOwner class (the class to which you add the include PricingPlans::PlanOwner mixin) automatically gains these helpers to check limits:
# Check limits for a relationship
user.plan_limit_remaining(:projects) # => integer or :unlimited
user.plan_limit_percent_used(:projects) # => Float percent
user.within_plan_limits?(:projects, by: 1) # => true/false
# Grace helpers
user.grace_active_for?(:projects) # => true/false
user.grace_ends_at_for(:projects) # => Time or nil
user.grace_remaining_seconds_for(:projects) # => Integer seconds
user.grace_remaining_days_for(:projects) # => Integer days (ceil)
user.plan_blocked_for?(:projects) # => true/false (considering after_limit policy)We also add syntactic sugar methods. For example, if your plan defines a limit for :projects and you have a has_many :projects relationship, you also get these methods:
# Check limits (per `limits` key)
user.projects_remaining
user.projects_percent_used
user.projects_within_plan_limits?
# Grace helpers (per `limits` key)
user.projects_grace_active?
user.projects_grace_ends_at
user.projects_blocked?These methods are dynamically generated for every has_many :<limit_key>, like this:
<limit_key>_remaining<limit_key>_percent_used<limit_key>_within_plan_limits?(optionally:<limit_key>_within_plan_limits?(by: 1))<limit_key>_grace_active?<limit_key>_grace_ends_at<limit_key>_blocked?
If you want to get an aggregate of graces across multiple keys instead of checking them individually:
# Aggregates across keys
user.any_grace_active_for?(:products, :activations)
user.earliest_grace_ends_at_for(:products, :activations)You can also check for feature flags like this:
user.plan_allows?(:api_access) # => true/falseOf course, there's also dynamic syntactic sugar of the form plan_allows_<feature_key>?, like this:
user.plan_allows_api_access?plan_allows? is entitlement-aware, not just plan-aware: it also honors grandfathered features and per-owner grants (since 0.6.0), so it stays the one predicate to gate on even after you reprice. To see why an owner is entitled:
user.feature_entitlement_source(:api_access) # => :plan | :grandfather | :grant | nilChecking the current usage with respect to plan limits comes in handy, especially when building views. The following methods are useful to build warning / alert snippets, upgrade prompts, usage trackers, etc.
You can check the status of a single limit with user.limit(:projects)
This always returns a single StatusItem, which represents one status item for a limit. For example, output for user.limit(:projects):
#<struct
key=:projects,
human_key="projects",
current=1,
allowed=1,
percent_used=100.0,
grace_active=false,
grace_ends_at=nil,
blocked=true,
per=false,
severity=:at_limit,
severity_level=2,
message="You’ve reached your limit for projects (1/1). Upgrade your plan to unlock more.",
overage=0,
configured=true,
unlimited=false,
remaining=0,
after_limit=:block_usage,
:attention?=true,
:next_creation_blocked?=true,
warn_thresholds=[0.6, 0.8, 0.95],
next_warn_percent=nil,
period_start=nil,
period_end=nil,
period_seconds_remaining=nil
>As you can see, the StatusItem object returns a bunch of useful information for that limit. Something that may have caught your attention is severity and severity_level. For each limit, pricing_plans computes severity, to help you better organize and display warning messages / alerts to your users.
Severity order: :blocked > :grace > :at_limit > :warning > :ok
Their corresponding severity_level are: 4, 3, 2, 1, 0; respectively.
Each severity comes with a default title:
blocked: "Cannot create more resources"grace: "Limit Exceeded (Grace Active)"at_limit: "At Limit"warning: "Approaching Limit"ok:nil
Each severity Messages come from your config.message_builder in pricing_plans.rb when present; otherwise we provide sensible defaults:
blocked: "Cannot create more on your current plan."grace: "Over the limit, grace active until ."at_limit: "You are at / . The next will exceed your plan."warning: "You have used / ."ok:nil
You can call user.limits (plural, no arguments) to get the current status of all limits. You will get an array of StatusItem objects, with the same keys as described above.
Sample output:
user.limits
# => [
# #<struct key=:limit_1...>,
# #<struct key=:limit_2...>,
# #<struct key=:limit_3...>
# ]Of course, prefer user.limit(:key) (singular, one argument) when you only need a the status of a single limit.
You can also filter which limits you get status items for, by passing their limits keys as arguments:
user.limits(:projects, :posts)limits_overview is a thin wrapper around limits that, on top of returning the array of StatusItem objects, returns you a few "overall helpers" that can help you let the user know the overall status of their plan usage in a single view.
limits_overview returns a JSON containing:
severity: highest severity out of all limitsseverity_levelcorresponding severity leveltitle: overall severity titlemessage: overall severity messageattention?: whether overall limits require user attention or notkeys: array of all computed limits keyshighest_keys: array of limits keys with the highest severityhighest_limits: array ofStatusItemkeys_sentence: limit keys requiring attention, in a readable sentence
For example:
user.limits_overviewWould output:
{
severity: :at_limit,
severity_level: 2,
title: "At your plan limit",
message: "You have reached your plan limit for products.",
attention?: true,
keys: [:products, :licenses, :activations],
highest_keys: [:products],
highest_limits: [
#<struct key=:projects...>
],
keys_sentence: "products",
noun: "plan limit",
has_have: "has",
cta_text: "View Plans",
cta_url: nil
} Of course, you can also pass limit keys as arguments to filter the output, like: user.limits_overview(:projects, :posts)
If you only want to get the overall severity of message of all keys, you can do:
user.limits_severity(:projects, :posts) # => :ok | :warning | :at_limit | :grace | :blocked
user.limits_message(:projects, :posts) # => String (combined human message string) or `nil`Additional per-limit checks:
user.limit_overage(:projects) # => Integer (0 if within)
user.limit_alert(:projects) # => { visible?: true/false, severity:, title:, message:, overage:, cta_text:, cta_url: }You also get these handy helpers:
user.attention_required_for_limit?(:projects) # => true | false` (alias for any of warning/grace/blocked)
user.approaching_limit?(:projects, at: 0.9) # => true | false` (uses highest `warn_at` if `at` omitted)You can also use the top-level equivalents if you prefer: PricingPlans.severity_for(user, :projects) and friends.
An override is an intentional exception to normal plan resolution. It is useful for gifts, employee accounts, support interventions, demos, and grandfathering. The method names deliberately say override because an override always takes precedence over both Pay subscriptions and the configured default:
user.current_pricing_plan # => PricingPlans::Plan
user.current_pricing_plan_source # => :assignment, :subscription, :default
user.current_pricing_plan_resolution # => PricingPlans::PlanResolution
user.override_pricing_plan!(:pro, source: "admin") # create/update an explicit override
user.pricing_plan_overridden? # => true
user.pricing_plan_override # => PricingPlans::Assignment
user.pricing_plan_override_source # => "admin"
user.clear_pricing_plan_override! # resume subscription/default resolutionWarning
Explicitly overriding to the configured default plan is still an override. For
example, user.override_pricing_plan!(:free, source: "admin_downgrade")
creates an assignment row and continues to take precedence over future Pay
subscriptions. That behavior supports intentional default-tier pinning. For an
ordinary free/default account, create no assignment. If an override already
exists, call user.clear_pricing_plan_override! to resume normal resolution.
The source: keyword is required by the explicit API. Use a stable, auditable description of why the exception exists—such as "admin", "customer_success_gift", "employee_access", or "legacy_import". The source is provenance; it does not alter precedence. Every override wins regardless of its source label.
Newly generated assignment tables do not have a database default for source, so direct model writes must also state their provenance. Applications installed with an older gem version may still have the historical "manual" database default; the explicit public API does not rely on it.
assign_pricing_plan! and remove_pricing_plan! are deprecated because their names do not reveal that they create and clear persistent overrides. Replace them directly:
# Before: ambiguous and source defaulted silently to "manual"
user.assign_pricing_plan!(:pro)
user.remove_pricing_plan!
# After: intent and provenance are explicit
user.override_pricing_plan!(:pro, source: "admin")
user.clear_pricing_plan_override!Legacy calls continue to work during the deprecation window and emit a gem-specific Active Support deprecation warning. Assigning the configured default through a legacy API receives an additional guard because it is usually accidental. Its behavior is configurable:
PricingPlans.configure do |config|
# :warn — default for upgraded applications; preserve behavior and warn
# :raise — recommended; reject legacy default-plan assignments before writing
# :allow — migration escape hatch; emit only the general deprecation warning
config.legacy_default_plan_assignment_behavior = :raise
endThe strict :raise behavior raises PricingPlans::LegacyDefaultPlanAssignmentError and writes no assignment. It never blocks the explicit override_pricing_plan! API, even when the target is the default plan, because that method already states the caller's intent. Newly generated initializers use :raise.
The low-level legacy methods—PricingPlans::PlanResolver.assign_plan_manually!, PricingPlans::PlanResolver.remove_manual_assignment!, PricingPlans::Assignment.assign_plan_to, and PricingPlans::Assignment.remove_assignment_for—are deprecated under the same policy. Prefer PlanResolver.override_pricing_plan_for!, PlanResolver.clear_pricing_plan_override_for!, Assignment.create_or_update_pricing_plan_override_for!, and Assignment.clear_pricing_plan_override_for! when a lower-level API is genuinely necessary. Models that include PricingPlans::Limitable also expose the symmetric class-level pair override_pricing_plan_for! and clear_pricing_plan_override_for! for compatibility with their previous owner-aware assignment helper.
Performance note: Each plan/provenance helper performs a fresh database lookup. If you need both the effective plan and its provenance, call current_pricing_plan_resolution once and read both from that object.
If you need the full provenance, use the resolution object:
resolution = user.current_pricing_plan_resolution
resolution.plan.key # => :enterprise
resolution.source # => :assignment (compatibility value)
resolution.pricing_plan_overridden? # => true
resolution.pricing_plan_override # => PricingPlans::Assignment | nil
resolution.pricing_plan_override_source # => "admin" | "legacy_import" | nil
resolution.subscription # => Pay subscription | nil
# Storage-oriented compatibility readers remain available:
resolution.assignment # => same object as pricing_plan_override
resolution.assignment_source # => same value as pricing_plan_override_sourceThis distinction matters: the effective pricing plan controls entitlements and limits inside your app, while the Pay/Stripe subscription state is billing-facing. An explicit override may intentionally control entitlements while the underlying subscription remains available for billing operations. Clearing the override does not cancel or modify billing; it simply lets subscription/default resolution take control again.
Edge case: source can be :default even when subscription is non-nil. This happens when a Pay subscription exists but its processor_plan (Stripe price ID) doesn't map to any plan in your registry. The subscription is preserved for billing context, but the effective plan falls back to your configured default.
resolution.to_h is handy for inspection and tests, but it preserves the raw plan, assignment, and subscription objects. If you need a JSON-safe payload, build one explicitly from the scalar fields you care about.
user.on_free_plan? # => true/falseAnd finally, you get very thin convenient wrappers if you're using the pay gem:
# Pay (Stripe) convenience (returns false/nil when Pay is absent)
# Note: this is billing-facing state, distinct from our in-app
# enforcement grace which is tracked per-limit, and distinct from
# the effective plan resolved by current_pricing_plan.
user.pay_subscription_active? # => true/false
user.pay_on_trial? # => true/false
user.pay_on_grace_period? # => true/falseThe PlanOwner mixin provides class-level scopes for querying plan owners by their limits status. These are useful for building admin dashboards to find organizations that need attention.
# Find plan owners with any exceeded limit (includes grace period and blocked)
Organization.with_exceeded_limits
# Find plan owners that are blocked (grace period expired)
Organization.with_blocked_limits
# Find plan owners in grace period (exceeded but not yet blocked)
Organization.in_grace_period
# Find plan owners with no exceeded limits
Organization.within_all_limits
# Alias for with_exceeded_limits - plan owners needing attention
Organization.needing_attentionThese scopes are fully chainable with other ActiveRecord methods:
# Find exceeded organizations created this month
Organization.with_exceeded_limits.where(created_at: 1.month.ago..)
# Paginate blocked organizations
Organization.with_blocked_limits.order(:created_at).limit(10)
# Count organizations in grace period
Organization.in_grace_period.count# app/controllers/admin/dashboard_controller.rb
def show
@orgs_needing_attention = Organization.needing_attention.count
@orgs_in_grace = Organization.in_grace_period.count
@orgs_blocked = Organization.with_blocked_limits.count
@orgs_healthy = Organization.within_all_limits.count
endFor large tables, ensure you have the composite index on enforcement_states:
add_index :pricing_plans_enforcement_states,
[:plan_owner_type, :plan_owner_id, :exceeded_at],
name: 'index_enforcement_states_on_owner_and_exceeded'