You define plans and their limits and features as code in the pricing_plans.rb initializer. The pricing_plans offers you a DSL that makes plan definition intuitive and read like plain English.
To define a free plan, for example, you would do:
PricingPlans.configure do |config|
plan :free do
price 0
default!
end
endThat's the basics! Let's dive in.
Important
You must set a default plan (either mark one with default! in the plan DSL or set config.default_plan = :your_plan_key).
Plans are secure by default: features are disabled and limits are set to 0 unless explicitly configured.
At a high level, a plan does two things: (1) Gate features (2) Enforce limits (quotas)
Let's start by giving access to certain features. For example, our free plan could give users API access:
PricingPlans.configure do |config|
plan :free do
price 0
allows :api_access
end
endWe're just defining what the plan does now. Later, we'll see all the methods we can use to enforce these limits and gate these features very easily.
Features and limits are secure by default: features are disabled and limits are set to 0 unless explicitly allowed. For clarity, you can explicitly state what a plan disallows, but this is just cosmetic:
PricingPlans.configure do |config|
plan :free do
price 0
allows :api_access
disallows :premium_features
end
endThis wouldn't do anything, though, because all features are disabled by default; but it makes it obvious what the plan does and doesn't.
The other thing plans can do is enforce a limit. We can define limits like this:
PricingPlans.configure do |config|
plan :free do
price 0
allows :api_access
limits :projects, to: 3
end
endThe limits :projects, to: 3 does exactly that: whoever has this plan can only have three projects at most. We'll see later how to tie this limit to the actual model relationship, but for now, we're just defining the limit.
What happens after a limit is reached is controlled by after_limit. The default is :block_usage. You can customize per limit. Examples:
# Just warn (never block):
PricingPlans.configure do |config|
plan :free do
price 0
allows :api_access
limits :projects, to: 3, after_limit: :just_warn
end
endIf we want to prevent more resources being created after the limit has been reached, we can :block_usage:
# Block immediately:
PricingPlans.configure do |config|
plan :free do
price 0
allows :api_access
limits :projects, to: 3, after_limit: :block_usage
end
endHowever, we can be nicer and give users a bit of a grace period after the limit has been reached. To do that, we use :grace_then_block:
# Opt into grace, then block:
PricingPlans.configure do |config|
plan :free do
price 0
allows :api_access
limits :projects, to: 3, after_limit: :grace_then_block
end
endWe can also specify how long the grace period is:
PricingPlans.configure do |config|
plan :free do
price 0
allows :api_access
limits :projects, to: 3, after_limit: :grace_then_block, grace: 7.days
end
endIn summary: persistent caps count live rows (per plan owner model). When over the cap:
:just_warn→ validation passes; use controller guard to warn.:block_usage→ validation fails immediately (useserror_after_limitif set).:grace_then_block→ validation fails once grace is considered “blocked” (we track and switch from grace to blocked).
Note: grace is only valid with blocking behaviors. We’ll raise at boot if you set grace with :just_warn.
Besides persistent caps, a limit can be defined as a per‑period allowance that resets each window. Example:
plan :pro do
# Allow up to 3 custom models per calendar month
limits :custom_models, to: 3, per: :calendar_month
endAccepted per: values:
:billing_cycle(default globally; respects Pay subscription anchors if available, else falls back to calendar month):calendar_month,:calendar_week,:calendar_day- A callable:
->(plan_owner) { [start_time, end_time] } - An ActiveSupport duration:
2.weeks(window starts at beginning of day)
Per‑period usage is tracked in the PricingPlans::Usage model (pricing_plans_usages table) and read live. Persistent caps do not use this table.
- Default period: Controlled by
config.period_cycle(defaults to:billing_cycle). You can override per limit withper:. - Billing cycle: When
payis available, we use the subscription’s anchors (current_period_start/current_period_end). If not available, we fall back to a monthly window anchored at the subscription’screated_at. If there is no subscription, we fall back to calendar month. - Calendar windows:
:calendar_month,:calendar_week,:calendar_daymap tobeginning_of_* … end_of_*for the current time. - Duration windows: For
ActiveSupport::Duration(e.g.,2.weeks), the window starts atbeginning_of_dayand ends atstart + duration. - Custom callable: You can pass
->(plan_owner) { [start_time, end_time] }. We validate that both are present andend > start.
- Include
limited_by_pricing_planson the model that represents the metered object. Onafter_create, we atomically upsert/increment the current period’s usage row for thatplan_ownerandlimit_key. - Concurrency: we de‑duplicate with a uniqueness constraint and retry on
RecordNotUniqueto increment safely. - Reads are live:
LimitChecker.current_usage_for(plan_owner, :key)returns the current window’sused(or 0 if none).
Callback timing:
- We increment usage in an
after_createcallback (notafter_commit). This runs inside the same database transaction as the record creation, so if the outer transaction rolls back, the usage increment rolls back as well.
- State lives in
pricing_plans_enforcement_statesper plan_owner+limit. - Per‑period limits:
- We stamp the active window on the state; when the window changes, stale state is discarded automatically (warnings re‑arm and grace resets at each new window).
- Warnings: thresholds re‑arm every window; the same threshold can emit again in the next window.
- Grace: if
:grace_then_block, grace is per window. A new window clears prior grace/blocked state.
- Persistent caps:
- Warnings are monotonic: once a higher
warn_atthreshold has been emitted, we do not re‑emit lower or equal thresholds again unless you clear state viaPricingPlans::GraceManager.reset_state!(plan_owner, :limit_key). - Grace is absolute: if
:grace_then_block, we start grace once the limit is exceeded. It expires after the configured duration. There is no automatic reset tied to time windows. Enforcement for creates is still driven by “would this action exceed the cap now?”. If usage drops below the cap, create checks will pass again even if a prior state exists. - You may clear any existing warning/grace/blocked state manually with
reset_state!.
- Warnings are monotonic: once a higher
# pro allows 3 custom models per month
PricingPlans::Assignment.assign_plan_to(org, :pro)
travel_to(Time.parse("2025-01-15 12:00:00 UTC")) do
3.times { org.custom_models.create!(name: "Model") }
PricingPlans::LimitChecker.plan_limit_remaining(org, :custom_models)
# => 0
result = PricingPlans::ControllerGuards.require_plan_limit!(:custom_models, plan_owner: org)
result.grace? # => true when after_limit: :grace_then_block
end
travel_to(Time.parse("2025-02-01 12:00:00 UTC")) do
# New window — counters reset automatically
PricingPlans::LimitChecker.plan_limit_remaining(org, :custom_models)
# => 3
endpricing_plans provides you with a few useful callbacks. Callbacks allow you to set thresholds to warn our users when important things happen. For example, when they're halfway through their limit, approaching the limit, etc.
You can use callbacks for:
- Upsell emails: "You've used 80% of your Pro plan - upgrade for more!"
- Usage alerts: "You're approaching your API request limit"
- Grace period warnings: "You've exceeded your limit. Upgrade within 7 days."
- Churn prevention: Proactively reach out before users hit walls
Callbacks fire automatically when limited models are created, you just need to define them like this:
# config/initializers/pricing_plans.rb
PricingPlans.configure do |config|
plan :pro do
limits :licenses, to: 100, warn_at: [0.8, 0.95], after_limit: :grace_then_block, grace: 7.days
limits :activations, to: 300, warn_at: [0.8, 0.95], after_limit: :grace_then_block, grace: 7.days
end
# Fires when usage crosses a warning threshold (80%, 95%, etc.)
config.on_warning(:licenses) do |plan_owner, limit_key, threshold|
# Example: "You've used 80% of your licenses"
UsageWarningMailer.approaching_limit(plan_owner, limit_key, threshold).deliver_later
end
# Fires when limit is exceeded and grace period begins
config.on_grace_start(:licenses) do |plan_owner, limit_key, grace_ends_at|
# Example: "You've hit your license limit. Upgrade within 7 days or service will be interrupted."
GracePeriodMailer.limit_exceeded(plan_owner, limit_key, grace_ends_at).deliver_later
end
# Fires when grace period expires and user is blocked
config.on_block(:licenses) do |plan_owner, limit_key|
# Example: "Your grace period has ended. Please upgrade to continue creating licenses."
BlockedMailer.access_blocked(plan_owner, limit_key).deliver_later
end
end| Callback | When it fires | Arguments |
|---|---|---|
on_warning(limit_key) |
When usage crosses a warn_at threshold |
plan_owner, limit_key, threshold (e.g., 0.8) |
on_grace_start(limit_key) |
When limit is exceeded with grace_then_block |
plan_owner, limit_key, grace_ends_at (Time) |
on_block(limit_key) |
When grace expires or with :block_usage policy |
plan_owner, limit_key |
Note: Callbacks now receive
limit_keyas the second argument. Both old and new signatures are supported for backward compatibility:# Old signature (still works) config.on_warning(:projects) { |plan_owner, threshold| ... } # New signature (recommended) config.on_warning(:projects) { |plan_owner, limit_key, threshold| ... }
You can also register a single callback that fires for all limits by omitting the limit_key argument:
# Fires for ANY limit warning (projects, licenses, api_calls, etc.)
config.on_warning do |plan_owner, limit_key, threshold|
Analytics.track(plan_owner, "limit_warning", limit: limit_key, threshold: threshold)
endWhen both specific and wildcard handlers are registered, both fire (specific first, then wildcard). This is useful for combining per-limit emails with universal analytics:
# Specific: send targeted email for projects
config.on_warning(:projects) do |plan_owner, limit_key, threshold|
ProjectLimitMailer.warning(plan_owner, threshold).deliver_later
end
# Wildcard: log all warnings to analytics
config.on_warning do |plan_owner, limit_key, threshold|
Analytics.track(plan_owner, "limit_warning", limit: limit_key, threshold: threshold)
endConfigure warning thresholds to get notified before users hit their limits:
limits :projects, to: 100, warn_at: [0.6, 0.8, 0.95]
# Fires at 60%, 80%, and 95% usageEach threshold fires only once per limit window (e.g., once per billing cycle for per-period limits).
Callbacks are error-isolated, meaning that if your callback raises an exception, it won't break the model creation or any other operation. Errors are logged but don't propagate. This ensures your app keeps working even if your mailer or analytics service is down.
Warning and grace callbacks use after_commit hooks, so they only fire after the database transaction successfully commits. If a transaction rolls back, these callbacks won't fire.
Block callbacks fire during validation (before commit) because they indicate a failed creation attempt. This means block emails may be sent even if the surrounding transaction rolls back - which is correct behavior since the user did experience being blocked from creating the record.
Automatic callbacks run on every model creation for limited models. For each limit key configured on a model, the callback:
- Resolves the plan owner
- Fetches the effective plan
- Calculates current usage (may involve a COUNT query)
- Checks warning thresholds
- Updates EnforcementState if needed
For most applications, this overhead is negligible. However, if you're doing high-volume batch inserts of limited models, consider:
- Using
insert_allor raw SQL for bulk operations (bypasses callbacks) - Temporarily disabling callbacks with
Model.skip_callbackduring batch jobs - Adding indexes on foreign keys used for counting (e.g.,
add_index :projects, :organization_id)
That's it! When a Pro user creates their 20th project (80% of 25), they get an upsell email. At 25, grace starts. When grace expires, they're blocked. Per-month limits like file uploads reset each billing cycle. All completely automatic with zero maintenance overhead.
If you only want a scope, like active projects, to count towards plan limits, you can do:
PricingPlans.configure do |config|
plan :free do
price 0
allows :api_access
limits :projects, to: 3, count_scope: :active
end
end(Assuming, of course, that your Project model has an active scope)
Undefined limits default to 0 (blocked). To explicitly allow unlimited access, use the unlimited helper:
PricingPlans.configure do |config|
plan :enterprise do
price 999
allows :api_access
unlimited :projects # Explicit unlimited
# :storage undefined → 0 (blocked)
end
endTo summarize, here's what persistent caps (plan limits) are:
-
Counting is live:
SELECT COUNT(*)scoped to the plan owner association, no counter caches. -
Validation on create: blocks immediately on
:block_usage, or blocks when grace is considered “blocked” on:grace_then_block.:just_warnpasses. -
Deletes automatically lower the count. Backfills simply reflect current rows.
-
Filtered counting via count_scope: scope persistent caps to active-only rows.
- Idiomatic options:
- Plan DSL with AR Hash:
limits :licenses, to: 25, count_scope: { status: 'active' } - Plan DSL with named scope:
limits :activations, to: 50, count_scope: :active - Plan DSL with multiple:
limits :seats, to: 10, count_scope: [:active, { kind: 'paid' }]
- Plan DSL with AR Hash:
- Idiomatic options:
-
Macro form on the child model:
limited_by_pricing_plans :licenses, plan_owner: :organization, count_scope: :active -
plan_owner‑side convenience:
has_many :licenses, limited_by_pricing_plans: { limit_key: :licenses, count_scope: :active } -
Full freedom:
->(rel) { rel.where(status: 'active') }or->(rel, plan_owner) { rel.where(organization_id: plan_owner.id) }- Accepted types: Symbol (named scope), Hash (where), Proc (arity 1 or 2), or Array of these (applied left-to-right).
- Precedence: plan-level
count_scopeoverrides macro-levelcount_scope. - Restriction:
count_scopeonly applies to persistent caps (not allowed on per-period limits). - Performance: add indexes for your filters (e.g.,
status,deactivated_at).
Since pricing_plans.rb is our single source of truth for plans, we can define plan information we can later use to show pricing tables, like plan name, description, and bullet points. We can also override the price for a string, and we can set a CTA button text and URL to link to:
PricingPlans.configure do |config|
plan :free do
price_string "Free!"
name "Free Plan" # optional, would default to "Free" as inferred from the :free key
description "A plan to get you started"
bullets "Basic features", "Community support"
metadata icon: "rocket", color: "bg-red-500"
cta_text "Subscribe"
# In initializers, prefer a string path/URL or set a global default CTA in config.
# Route helpers are not available here.
cta_url "/pricing"
allows :api_access
limits :projects, to: 3
end
endYou can also make a plan default!; and you can make a plan highlighted! to help you when building a pricing table.
You can attach arbitrary metadata to a plan for presentation needs (for example, per-card icons or colors on a pricing page). This keeps plan UI details co-located in the same DSL rather than scattered elsewhere:
plan :hobby do
metadata icon: "rocket", color: "bg-red-500"
end
plan.metadata[:icon] # => "rocket"You can mark a plan as hidden! to exclude it from public-facing plan lists (PricingPlans.plans, PricingPlans.for_pricing, PricingPlans.view_models). Hidden plans are still accessible internally and can be assigned to users.
Use cases for hidden plans:
- Default plan for unsubscribed users: Create a
hidden!plan with zero limits as your default for users who haven't subscribed yet. This is useful, for example, if you don't want free users in your app (everyone needs to pay) -- thehidden!plan would get assigned to every user by default (to implicitly block access to all features) until they subscribe - Grandfathered plans: Old plans you no longer offer to new customers, but existing users still have
- Internal/testing plans: Plans for employees, beta testers, or special partnerships
- Deprecated plans: Plans being phased out but still active for some users
PricingPlans.configure do |config|
# Hidden default plan for users who haven't subscribed
# It won't appear on pricing page
# This is what users are on before they subscribe to any plan
plan :unsubscribed do
price 0
hidden! # Won't appear on pricing page
default!
# No limits defined - everything defaults to 0 (blocked)
end
# Visible plans for your pricing page
plan :starter do
price 10
limit :projects, to: 5
end
# Grandfathered plan (hidden from new customers)
plan :legacy_2020 do
price 15
hidden! # Existing customers keep it, but won't show on pricing page
limit :projects, to: 100
end
endImportant notes:
- Hidden plans can be the
default!plan (common pattern for "unsubscribed" users) - Hidden plans cannot be the
highlighted!plan (validation error - highlighted plans must be visible) - Users can still be on hidden plans (via Pay subscription, manual assignment, or default)
- Internal APIs (
Registry.plans,PlanResolver) can still access hidden plans - Pay gem can still resolve subscriptions to hidden plans (useful for grandfathered customers)
If we're defining a paid plan, and if you're already using the pay gem, you can omit defining the explicit price, and just let the gem read the actual price from Stripe via pay:
PricingPlans.configure do |config|
plan :pro do
stripe_price "price_123abc"
description "For growing teams and businesses"
bullets "Advanced features", "Priority support", "API access"
allows :api_access, :premium_features
limits :projects, to: 10
unlimited :team_members
highlighted!
end
endIf you have monthly and yearly prices for the same plan, you can define them like:
PricingPlans.configure do |config|
plan :pro do
stripe_price month: "price_123abc", year: "price_456def"
end
endstripe_price accepts String or Hash (e.g., { month:, year:, id: }) and the pricing_plans PlanResolver maps against Pay's subscription.processor_plan.
A common use case of pricing pages is adding a free and an enterprise plan around the regular paid plans that you may define in Stripe. The free plan is usually just a limited free tier, not associated with any external price ID; while the "Enterprise" plan may just redirect users to a sales email. To achieve this, we can do:
# Your free plan here
# Then your paid plans here, linked to Stripe IDs
# And finally, an enterprise plan:
plan :enterprise do
price_string "Contact"
description "Get in touch and we'll fit your needs."
bullets "Custom limits", "Dedicated SLAs", "Dedicated support"
cta_text "Contact us"
cta_url "mailto:sales@example.com"
unlimited :products
allows :api_access, :premium_features
end