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.
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?Checking 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.
You can also check and override the current pricing plan for any user, which comes handy as an admin:
user.current_pricing_plan # => PricingPlans::Plan
user.current_pricing_plan_source # => :assignment, :subscription, :default
user.current_pricing_plan_resolution # => PricingPlans::PlanResolution
user.assign_pricing_plan!(:pro) # manual assignment override
user.remove_pricing_plan! # remove manual override (fallback to default)Performance note: Each call to current_pricing_plan, current_pricing_plan_source, or current_pricing_plan_resolution performs a fresh database lookup. If you need both the plan and its provenance, call current_pricing_plan_resolution once and read both values from that object — this avoids duplicate queries.
If you need the full provenance, use the resolution object:
resolution = user.current_pricing_plan_resolution
resolution.plan.key # => :enterprise
resolution.source # => :assignment
resolution.assignment # => PricingPlans::Assignment | nil
resolution.assignment_source # => "admin" | "manual" | nil
resolution.subscription # => Pay subscription | nilThis distinction matters: the effective pricing plan is what controls entitlements and limits inside your app. The Pay/Stripe subscription state is billing-facing. A manual assignment may intentionally override the subscription-backed plan while still leaving the underlying subscription present for billing operations.
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'