Skip to content

Latest commit

 

History

History
467 lines (341 loc) · 19.5 KB

03-model-helpers.md

File metadata and controls

467 lines (341 loc) · 19.5 KB

Model helpers and methods

Define your PlanOwner class

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
end

By adding the PricingPlans::PlanOwner mixin to a model, you automatically get all the features described below.

Multiple plan-owner models (two-sided marketplaces)

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.user or Current.organization as appropriate.
  • For pricing pages, tag plans with metadata (e.g. meta audience: "users" vs "organizations") and filter PricingPlans.plans by 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.

Link plan limits to your PlanOwner model

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
end

Then 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
end

The :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 }
end

In 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
end

You 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
end

Enforce limits in your PlanOwner class

The 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)

Gate features in your PlanOwner class

You can also check for feature flags like this:

user.plan_allows?(:api_access)               # => true/false

Of 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 | nil

Usage and limits status

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.

limit: Check a single limit

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
>

The StatusItem object

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

limits: Get the status of all limits

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: Get a summary of all limits

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 limits
  • severity_level corresponding severity level
  • title: overall severity title
  • message: overall severity message
  • attention?: whether overall limits require user attention or not
  • keys: array of all computed limits keys
  • highest_keys: array of limits keys with the highest severity
  • highest_limits: array of StatusItem
  • keys_sentence: limit keys requiring attention, in a readable sentence

For example:

user.limits_overview

Would 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)

Limits aggregates

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.

Other helpers and methods

Check and explicitly override plans

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 resolution

Warning

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.

Migrating from the old assignment API

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
end

The 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_source

This 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.

Misc

user.on_free_plan?                           # => true/false

pay integration

And 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/false

Admin dashboard scopes

The 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.

Available scopes

# 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_attention

Chainable with ActiveRecord

These 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

Example: Admin dashboard

# 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
end

Performance note

For 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'