pricing_plans 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.claude/settings.local.json +16 -0
- data/.rubocop.yml +137 -0
- data/CHANGELOG.md +83 -0
- data/LICENSE.txt +21 -0
- data/README.md +241 -0
- data/Rakefile +15 -0
- data/docs/01-define-pricing-plans.md +372 -0
- data/docs/02-controller-helpers.md +223 -0
- data/docs/03-model-helpers.md +318 -0
- data/docs/04-views.md +121 -0
- data/docs/05-semantic-pricing.md +159 -0
- data/docs/06-gem-compatibility.md +99 -0
- data/docs/images/pricing_plans_ruby_rails_gem_pricing_table.jpg +0 -0
- data/docs/images/pricing_plans_ruby_rails_gem_usage_alert_upgrade.jpg +0 -0
- data/docs/images/pricing_plans_ruby_rails_gem_usage_meter.jpg +0 -0
- data/docs/images/product_creation_blocked.jpg +0 -0
- data/lib/generators/pricing_plans/install/install_generator.rb +42 -0
- data/lib/generators/pricing_plans/install/templates/create_pricing_plans_tables.rb.erb +91 -0
- data/lib/generators/pricing_plans/install/templates/initializer.rb +100 -0
- data/lib/pricing_plans/association_limit_registry.rb +45 -0
- data/lib/pricing_plans/configuration.rb +189 -0
- data/lib/pricing_plans/controller_guards.rb +574 -0
- data/lib/pricing_plans/controller_rescues.rb +115 -0
- data/lib/pricing_plans/dsl.rb +44 -0
- data/lib/pricing_plans/engine.rb +69 -0
- data/lib/pricing_plans/grace_manager.rb +227 -0
- data/lib/pricing_plans/integer_refinements.rb +48 -0
- data/lib/pricing_plans/job_guards.rb +24 -0
- data/lib/pricing_plans/limit_checker.rb +157 -0
- data/lib/pricing_plans/limitable.rb +286 -0
- data/lib/pricing_plans/models/assignment.rb +55 -0
- data/lib/pricing_plans/models/enforcement_state.rb +45 -0
- data/lib/pricing_plans/models/usage.rb +51 -0
- data/lib/pricing_plans/overage_reporter.rb +77 -0
- data/lib/pricing_plans/pay_support.rb +85 -0
- data/lib/pricing_plans/period_calculator.rb +183 -0
- data/lib/pricing_plans/plan.rb +653 -0
- data/lib/pricing_plans/plan_owner.rb +287 -0
- data/lib/pricing_plans/plan_resolver.rb +85 -0
- data/lib/pricing_plans/price_components.rb +16 -0
- data/lib/pricing_plans/registry.rb +182 -0
- data/lib/pricing_plans/result.rb +109 -0
- data/lib/pricing_plans/version.rb +5 -0
- data/lib/pricing_plans/view_helpers.rb +58 -0
- data/lib/pricing_plans.rb +645 -0
- data/sig/pricing_plans.rbs +4 -0
- metadata +236 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
# Define pricing plans in `pricing_plans.rb`
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
To define a free plan, for example, you would do:
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
PricingPlans.configure do |config|
|
|
9
|
+
plan :free do
|
|
10
|
+
price 0
|
|
11
|
+
default!
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
That's the basics! Let's dive in.
|
|
17
|
+
|
|
18
|
+
> [!IMPORTANT]
|
|
19
|
+
> You must set a default plan (either mark one with `default!` in the plan DSL or set `config.default_plan = :your_plan_key`).
|
|
20
|
+
|
|
21
|
+
## Define what each plan gives
|
|
22
|
+
|
|
23
|
+
At a high level, a plan needs to do **two** things:
|
|
24
|
+
(1) Gate features
|
|
25
|
+
(2) Enforce limits (quotas)
|
|
26
|
+
|
|
27
|
+
## (1) Gate features in a plan
|
|
28
|
+
|
|
29
|
+
Let's start by giving access to certain features. For example, our free plan could give users API access:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
PricingPlans.configure do |config|
|
|
33
|
+
plan :free do
|
|
34
|
+
price 0
|
|
35
|
+
|
|
36
|
+
allows :api_access
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
We'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](#gate-features-in-controllers) very easily.
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
All features are disabled by default unless explicitly made available with the `allows` keyword. However, for clarity we can explicitly say what the plan disallows:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
PricingPlans.configure do |config|
|
|
48
|
+
plan :free do
|
|
49
|
+
price 0
|
|
50
|
+
|
|
51
|
+
allows :api_access
|
|
52
|
+
disallows :premium_features
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
This wouldn't do anything, though, because all features are disabled by default; but it makes it obvious what the plan does and doesn't.
|
|
58
|
+
|
|
59
|
+
## (2) Enforce limits (quotas) in a plan
|
|
60
|
+
|
|
61
|
+
The other thing plans can do is enforce a limit. We can define limits like this:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
PricingPlans.configure do |config|
|
|
65
|
+
plan :free do
|
|
66
|
+
price 0
|
|
67
|
+
|
|
68
|
+
allows :api_access
|
|
69
|
+
|
|
70
|
+
limits :projects, to: 3
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The `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](#models), but for now, we're just **defining** the limit.
|
|
76
|
+
|
|
77
|
+
### `after_limit`: Define what happens after a limit is reached
|
|
78
|
+
|
|
79
|
+
What happens after a limit is reached is controlled by `after_limit`. The default is `:block_usage`. You can customize per limit. Examples:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
# Just warn (never block):
|
|
83
|
+
PricingPlans.configure do |config|
|
|
84
|
+
plan :free do
|
|
85
|
+
price 0
|
|
86
|
+
allows :api_access
|
|
87
|
+
limits :projects, to: 3, after_limit: :just_warn
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
If we want to prevent more resources being created after the limit has been reached, we can `:block_usage`:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
# Block immediately:
|
|
96
|
+
PricingPlans.configure do |config|
|
|
97
|
+
plan :free do
|
|
98
|
+
price 0
|
|
99
|
+
allows :api_access
|
|
100
|
+
limits :projects, to: 3, after_limit: :block_usage
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
However, 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`:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
# Opt into grace, then block:
|
|
109
|
+
PricingPlans.configure do |config|
|
|
110
|
+
plan :free do
|
|
111
|
+
price 0
|
|
112
|
+
allows :api_access
|
|
113
|
+
limits :projects, to: 3, after_limit: :grace_then_block
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
We can also specify how long the grace period is:
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
PricingPlans.configure do |config|
|
|
122
|
+
plan :free do
|
|
123
|
+
price 0
|
|
124
|
+
allows :api_access
|
|
125
|
+
limits :projects, to: 3, after_limit: :grace_then_block, grace: 7.days
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
In summary: persistent caps count live rows (per plan owner model). When over the cap:
|
|
131
|
+
- `:just_warn` → validation passes; use controller guard to warn.
|
|
132
|
+
- `:block_usage` → validation fails immediately (uses `error_after_limit` if set).
|
|
133
|
+
- `:grace_then_block` → validation fails once grace is considered “blocked” (we track and switch from grace to blocked).
|
|
134
|
+
|
|
135
|
+
Note: `grace` is only valid with blocking behaviors. We’ll raise at boot if you set `grace` with `:just_warn`.
|
|
136
|
+
|
|
137
|
+
### Per‑period allowances
|
|
138
|
+
|
|
139
|
+
Besides persistent caps, a limit can be defined as a per‑period allowance that resets each window. Example:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
plan :pro do
|
|
143
|
+
# Allow up to 3 custom models per calendar month
|
|
144
|
+
limits :custom_models, to: 3, per: :calendar_month
|
|
145
|
+
end
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Accepted `per:` values:
|
|
149
|
+
- `:billing_cycle` (default globally; respects Pay subscription anchors if available, else falls back to calendar month)
|
|
150
|
+
- `:calendar_month`, `:calendar_week`, `:calendar_day`
|
|
151
|
+
- A callable: `->(plan_owner) { [start_time, end_time] }`
|
|
152
|
+
- An ActiveSupport duration: `2.weeks` (window starts at beginning of day)
|
|
153
|
+
|
|
154
|
+
Per‑period usage is tracked in [the `PricingPlans::Usage` model (`pricing_plans_usages` table)](#why-the-models) and read live. Persistent caps do not use this table.
|
|
155
|
+
|
|
156
|
+
#### How period windows are calculated
|
|
157
|
+
|
|
158
|
+
- **Default period**: Controlled by `config.period_cycle` (defaults to `:billing_cycle`). You can override per limit with `per:`.
|
|
159
|
+
- **Billing cycle**: When `pay` is 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’s `created_at`. If there is no subscription, we fall back to calendar month.
|
|
160
|
+
- **Calendar windows**: `:calendar_month`, `:calendar_week`, `:calendar_day` map to `beginning_of_* … end_of_*` for the current time.
|
|
161
|
+
- **Duration windows**: For `ActiveSupport::Duration` (e.g., `2.weeks`), the window starts at `beginning_of_day` and ends at `start + duration`.
|
|
162
|
+
- **Custom callable**: You can pass `->(plan_owner) { [start_time, end_time] }`. We validate that both are present and `end > start`.
|
|
163
|
+
|
|
164
|
+
#### Automatic usage tracking (race‑safe)
|
|
165
|
+
|
|
166
|
+
- Include `limited_by_pricing_plans` on the model that represents the metered object. On `after_create`, we atomically upsert/increment the current period’s usage row for that `plan_owner` and `limit_key`.
|
|
167
|
+
- Concurrency: we de‑duplicate with a uniqueness constraint and retry on `RecordNotUnique` to increment safely.
|
|
168
|
+
- Reads are live: `LimitChecker.current_usage_for(plan_owner, :key)` returns the current window’s `used` (or 0 if none).
|
|
169
|
+
|
|
170
|
+
Callback timing:
|
|
171
|
+
- We increment usage in an `after_create` callback (not `after_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.
|
|
172
|
+
|
|
173
|
+
#### Grace/warnings and period rollover (explicit semantics)
|
|
174
|
+
|
|
175
|
+
- State lives in `pricing_plans_enforcement_states` per plan_owner+limit.
|
|
176
|
+
- Per‑period limits:
|
|
177
|
+
- 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).
|
|
178
|
+
- Warnings: thresholds re‑arm every window; the same threshold can emit again in the next window.
|
|
179
|
+
- Grace: if `:grace_then_block`, grace is per window. A new window clears prior grace/blocked state.
|
|
180
|
+
- Persistent caps:
|
|
181
|
+
- Warnings are monotonic: once a higher `warn_at` threshold has been emitted, we do not re‑emit lower or equal thresholds again unless you clear state via `PricingPlans::GraceManager.reset_state!(plan_owner, :limit_key)`.
|
|
182
|
+
- 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.
|
|
183
|
+
- You may clear any existing warning/grace/blocked state manually with `reset_state!`.
|
|
184
|
+
|
|
185
|
+
#### Example: usage resets next period
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
# pro allows 3 custom models per month
|
|
189
|
+
PricingPlans::Assignment.assign_plan_to(org, :pro)
|
|
190
|
+
|
|
191
|
+
travel_to(Time.parse("2025-01-15 12:00:00 UTC")) do
|
|
192
|
+
3.times { org.custom_models.create!(name: "Model") }
|
|
193
|
+
PricingPlans::LimitChecker.plan_limit_remaining(org, :custom_models)
|
|
194
|
+
# => 0
|
|
195
|
+
result = PricingPlans::ControllerGuards.require_plan_limit!(:custom_models, plan_owner: org)
|
|
196
|
+
result.grace? # => true when after_limit: :grace_then_block
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
travel_to(Time.parse("2025-02-01 12:00:00 UTC")) do
|
|
200
|
+
# New window — counters reset automatically
|
|
201
|
+
PricingPlans::LimitChecker.plan_limit_remaining(org, :custom_models)
|
|
202
|
+
# => 3
|
|
203
|
+
end
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### Warn users when they cross a limit threshold
|
|
207
|
+
|
|
208
|
+
We can also set thresholds to warn our users when they're halfway through their limit, approaching the limit, etc. To do that, we first set up trigger thresholds with `warn_at:`
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
PricingPlans.configure do |config|
|
|
212
|
+
plan :free do
|
|
213
|
+
price 0
|
|
214
|
+
|
|
215
|
+
allows :api_access
|
|
216
|
+
|
|
217
|
+
limits :projects, to: 3, after_limit: :grace_then_block, grace: 7.days, warn_at: [0.5, 0.8, 0.95]
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
And then, for each threshold and for each limit, an event gets triggered, and we can configure its callback in the `pricing_plans.rb` initializer:
|
|
223
|
+
|
|
224
|
+
```ruby
|
|
225
|
+
config.on_warning(:projects) do |plan_owner, threshold|
|
|
226
|
+
# send a mail or a notification
|
|
227
|
+
# this fires when :projects crosses 50%, 80% and 95% of its limit
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Also available:
|
|
231
|
+
config.on_grace_start(:projects) do |plan_owner, grace_ends_at|
|
|
232
|
+
# notify grace started; ends at `grace_ends_at`
|
|
233
|
+
end
|
|
234
|
+
config.on_block(:projects) do |plan_owner|
|
|
235
|
+
# notify usage is now blocked for :projects
|
|
236
|
+
end
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
If you only want a scope, like active projects, to count towards plan limits, you can do:
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
PricingPlans.configure do |config|
|
|
243
|
+
plan :free do
|
|
244
|
+
price 0
|
|
245
|
+
|
|
246
|
+
allows :api_access
|
|
247
|
+
|
|
248
|
+
limits :projects, to: 3, count_scope: :active
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
(Assuming, of course, that your `Project` model has an `active` scope)
|
|
254
|
+
|
|
255
|
+
You can also make something unlimited (again, just syntactic sugar to be explicit, everything is unlimited unless there's an actual limit):
|
|
256
|
+
|
|
257
|
+
```ruby
|
|
258
|
+
PricingPlans.configure do |config|
|
|
259
|
+
plan :free do
|
|
260
|
+
price 0
|
|
261
|
+
|
|
262
|
+
allows :api_access
|
|
263
|
+
|
|
264
|
+
unlimited :projects
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### "Limits" API reference
|
|
270
|
+
|
|
271
|
+
To summarize, here's what persistent caps (plan limits) are:
|
|
272
|
+
- Counting is live: `SELECT COUNT(*)` scoped to the plan owner association, no counter caches.
|
|
273
|
+
- Validation on create: blocks immediately on `:block_usage`, or blocks when grace is considered “blocked” on `:grace_then_block`. `:just_warn` passes.
|
|
274
|
+
- Deletes automatically lower the count. Backfills simply reflect current rows.
|
|
275
|
+
|
|
276
|
+
- Filtered counting via count_scope: scope persistent caps to active-only rows.
|
|
277
|
+
- Idiomatic options:
|
|
278
|
+
- Plan DSL with AR Hash: `limits :licenses, to: 25, count_scope: { status: 'active' }`
|
|
279
|
+
- Plan DSL with named scope: `limits :activations, to: 50, count_scope: :active`
|
|
280
|
+
- Plan DSL with multiple: `limits :seats, to: 10, count_scope: [:active, { kind: 'paid' }]`
|
|
281
|
+
- Macro form on the child model: `limited_by_pricing_plans :licenses, plan_owner: :organization, count_scope: :active`
|
|
282
|
+
- plan_owner‑side convenience: `has_many :licenses, limited_by_pricing_plans: { limit_key: :licenses, count_scope: :active }`
|
|
283
|
+
- Full freedom: `->(rel) { rel.where(status: 'active') }` or `->(rel, plan_owner) { rel.where(organization_id: plan_owner.id) }`
|
|
284
|
+
- Accepted types: Symbol (named scope), Hash (where), Proc (arity 1 or 2), or Array of these (applied left-to-right).
|
|
285
|
+
- Precedence: plan-level `count_scope` overrides macro-level `count_scope`.
|
|
286
|
+
- Restriction: `count_scope` only applies to persistent caps (not allowed on per-period limits).
|
|
287
|
+
- Performance: add indexes for your filters (e.g., `status`, `deactivated_at`).
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
## Define user-facing plan attributes
|
|
291
|
+
|
|
292
|
+
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:
|
|
293
|
+
|
|
294
|
+
```ruby
|
|
295
|
+
PricingPlans.configure do |config|
|
|
296
|
+
plan :free do
|
|
297
|
+
price_string "Free!"
|
|
298
|
+
|
|
299
|
+
name "Free Plan" # optional, would default to "Free" as inferred from the :free key
|
|
300
|
+
description "A plan to get you started"
|
|
301
|
+
bullets "Basic features", "Community support"
|
|
302
|
+
|
|
303
|
+
cta_text "Subscribe"
|
|
304
|
+
# In initializers, prefer a string path/URL or set a global default CTA in config.
|
|
305
|
+
# Route helpers are not available here.
|
|
306
|
+
cta_url "/pricing"
|
|
307
|
+
|
|
308
|
+
allows :api_access
|
|
309
|
+
|
|
310
|
+
limits :projects, to: 3
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
You can also make a plan `default!`; and you can make a plan `highlighted!` to help you when building a pricing table.
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
## Link paid plans to Stripe prices (requires `pay`)
|
|
319
|
+
|
|
320
|
+
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`:
|
|
321
|
+
|
|
322
|
+
```ruby
|
|
323
|
+
PricingPlans.configure do |config|
|
|
324
|
+
plan :pro do
|
|
325
|
+
stripe_price "price_123abc"
|
|
326
|
+
|
|
327
|
+
description "For growing teams and businesses"
|
|
328
|
+
bullets "Advanced features", "Priority support", "API access"
|
|
329
|
+
|
|
330
|
+
allows :api_access, :premium_features
|
|
331
|
+
limits :projects, to: 10
|
|
332
|
+
unlimited :team_members
|
|
333
|
+
highlighted!
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
If you have monthly and yearly prices for the same plan, you can define them like:
|
|
339
|
+
|
|
340
|
+
```ruby
|
|
341
|
+
PricingPlans.configure do |config|
|
|
342
|
+
plan :pro do
|
|
343
|
+
stripe_price month: "price_123abc", year: "price_456def"
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
`stripe_price` accepts String or Hash (e.g., `{ month:, year:, id: }`) and the `pricing_plans` PlanResolver maps against Pay's `subscription.processor_plan`.
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
## Example: define an enterprise plan
|
|
352
|
+
|
|
353
|
+
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:
|
|
354
|
+
|
|
355
|
+
```ruby
|
|
356
|
+
# Your free plan here
|
|
357
|
+
|
|
358
|
+
# Then your paid plans here, linked to Stripe IDs
|
|
359
|
+
|
|
360
|
+
# And finally, an enterprise plan:
|
|
361
|
+
plan :enterprise do
|
|
362
|
+
price_string "Contact"
|
|
363
|
+
|
|
364
|
+
description "Get in touch and we'll fit your needs."
|
|
365
|
+
bullets "Custom limits", "Dedicated SLAs", "Dedicated support"
|
|
366
|
+
cta_text "Contact us"
|
|
367
|
+
cta_url "mailto:sales@example.com"
|
|
368
|
+
|
|
369
|
+
unlimited :products
|
|
370
|
+
allows :api_access, :premium_features
|
|
371
|
+
end
|
|
372
|
+
```
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
|
|
2
|
+
# Controller helpers and methods
|
|
3
|
+
|
|
4
|
+
The `pricing_plans` gem ships with controller helpers that make it easy to gate features defined in your pricing plans, and enforce limits. For these controllers methods to work, you first need to let the gem know who the current "plan owner" object is. The plan owner is the object on which the plan limits are applied (typically, the same object that gets billed for a subscription: the current user, current organization, etc.)
|
|
5
|
+
|
|
6
|
+
## Setting things up for controllers
|
|
7
|
+
|
|
8
|
+
First of all, the gem needs a way to know what the current plan owner object is (the current user, current organization, etc.)
|
|
9
|
+
|
|
10
|
+
You can set this globally in the initializer:
|
|
11
|
+
```ruby
|
|
12
|
+
# config/initializers/pricing_plans.rb
|
|
13
|
+
PricingPlans.configure do |config|
|
|
14
|
+
# Either:
|
|
15
|
+
config.controller_plan_owner :current_organization
|
|
16
|
+
# Or:
|
|
17
|
+
# config.controller_plan_owner { current_account }
|
|
18
|
+
end
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
If this is not defined, `pricing_plans` will [auto-try common conventions](/lib/pricing_plans/controller_guards.rb):
|
|
22
|
+
- `current_organization`
|
|
23
|
+
- `current_account`
|
|
24
|
+
- `current_user`
|
|
25
|
+
- `current_team`
|
|
26
|
+
- `current_company`
|
|
27
|
+
- `current_workspace`
|
|
28
|
+
- `current_tenant`
|
|
29
|
+
- If you set `plan_owner_class` in `pricing_plans.rb`, we’ll also try `current_<plan_owner_class>`.
|
|
30
|
+
|
|
31
|
+
If these methods are already defined in your Application Controller or individual controller(s), there's nothing you need to do! For example: `pricing_plans` works out of the box with Devise, because Devise already defines `current_user` at the Application Controller level.
|
|
32
|
+
|
|
33
|
+
If none of those methods are defined, or if you want custom logic, we recommend defining / overriding the method in your `ApplicationController`:
|
|
34
|
+
```ruby
|
|
35
|
+
class ApplicationController < ActionController::Base
|
|
36
|
+
# Adapt to your auth/session logic
|
|
37
|
+
def current_organization
|
|
38
|
+
# Your lookup here (e.g., current_user.organization)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
If needed, you can override the plan owner per controller:
|
|
44
|
+
```ruby
|
|
45
|
+
class YourSpecificController < ApplicationController
|
|
46
|
+
pricing_plans_plan_owner :current_organization
|
|
47
|
+
|
|
48
|
+
# Or pass a block:
|
|
49
|
+
# pricing_plans_plan_owner { current_user&.organization }
|
|
50
|
+
end
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Once all of this is configured, you can gate features and enforce limits easily in your controllers.
|
|
54
|
+
|
|
55
|
+
## Gate features in controllers
|
|
56
|
+
|
|
57
|
+
Feature-gate any controller action with:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
before_action { gate_feature!(:api_access) }
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
You can also specify the plan owner to override global or per-controller settings:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
before_action { gate_feature!(:api_access, plan_owner: current_organization) }
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
We also provide syntactic sugar for each feature defined in your pricing plans. For example, if you defined `allows :api_access` in your plans, you can simply enforce it like this instead:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
before_action :enforce_api_access!
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
You can use it along with any other controller filters too:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
before_action :enforce_api_access!, only: [:create]
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
These `enforce_<feature_key>!` controller helper methods are dynamically generated for each of the features `<feature_key>` you defined in your plans. So, for the helper above to work, you would have to have defined a plan with `allows :api_access` in your `pricing_plans.rb` file.
|
|
82
|
+
|
|
83
|
+
When the feature is disallowed, the controller will raise a `FeatureDenied` (we rescue it by default). You can customize the response by overriding `handle_pricing_plans_feature_denied(error)` in your `ApplicationController`:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
class ApplicationController < ActionController::Base
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# Override the default 403 handler (optional)
|
|
90
|
+
def handle_pricing_plans_feature_denied(error)
|
|
91
|
+
# Custom HTML handling
|
|
92
|
+
redirect_to upgrade_path, alert: error.message, status: :see_other
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Enforce plan limits in controllers
|
|
98
|
+
|
|
99
|
+
You can enforce limits for any action:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
before_action { enforce_plan_limit!(:projects) }
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
You can also override who the plan owner is:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
before_action { enforce_plan_limit!(:projects, plan_owner: current_organization) }
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
As with feature gating, there is syntactic sugar per limit:
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
before_action :enforce_projects_limit!
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The pattern is `enforce_<limit_key>_limit!` -- a method gets generated for every different `<limit_key>` defined with the `limits` keyword in `pricing_plans.rb`.
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
You can also specify a custom redirect path that will override the global config:
|
|
121
|
+
```ruby
|
|
122
|
+
before_action { enforce_plan_limit!(:projects, plan_owner: current_organization, redirect_to: pricing_path) }
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
> [!IMPORTANT]
|
|
126
|
+
> Enforcing a plan limit means "checking if **one more** object can be created". That is the default behavior. If you need to check whether you are at distance 2, or distance _n_ from the limit, you can pass the `by` argument as described below.
|
|
127
|
+
|
|
128
|
+
In the example aboves, the gem assumes the action to call will only create one extra project. So, if the plan limit is 5, and you're currently at 4 projects, you can still create one extra one, and the action will get called. If your action creates more than one object per call (creating multiple objects at once, importing objects in bulk etc.) you can enforce it will stay within plan limits by passing the `by:` parameter like this:
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
before_action { enforce_projects_limit!(by: 10) } # Checks whether current_organization can create 10 more projects within its plan limits
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Getting the raw `result` from a limit check
|
|
135
|
+
|
|
136
|
+
The `require_plan_limit!` method is also available (`require_`, not `enforce_`). This method returns a raw `result` object which is the result of checking the limit with respect to the current plan owner. You can call these on `result`:
|
|
137
|
+
- `result.message`
|
|
138
|
+
- `result.ok?`
|
|
139
|
+
- `result.warning?`
|
|
140
|
+
- `result.grace?`
|
|
141
|
+
- `result.blocked?`
|
|
142
|
+
- `result.success?`
|
|
143
|
+
|
|
144
|
+
This is useful for checking and enforcing limits mid-action (rather than via a `before_action` hook):
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
def create
|
|
148
|
+
result = require_plan_limit!(:products, plan_owner: current_organization, by: 1)
|
|
149
|
+
|
|
150
|
+
if result.blocked? # ok?, warning?, grace?, blocked?, success?
|
|
151
|
+
# result.message is available:
|
|
152
|
+
redirect_to pricing_path, alert: result.message, status: :see_other and return
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# ...
|
|
156
|
+
Product.create!(...)
|
|
157
|
+
redirect_to products_path
|
|
158
|
+
end
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
You can also define how your application responds when a limit check blocks an action by defining `handle_pricing_plans_limit_blocked` in your controller:
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
class ApplicationController < ActionController::Base
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
def handle_pricing_plans_limit_blocked(result)
|
|
168
|
+
# Default behavior (HTML): flash + redirect_to(pricing_path) if defined; else render 403
|
|
169
|
+
# You can customize globally here. The Result carries rich context:
|
|
170
|
+
# - result.limit_key, result.plan_owner, result.message, result.metadata
|
|
171
|
+
redirect_to(pricing_path, status: :see_other, alert: result.message)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
`enforce_plan_limit!` invokes this handler when `result.blocked?`, passing a `Result` enriched with `metadata[:redirect_to]` resolved via:
|
|
177
|
+
1. explicit `redirect_to:` option
|
|
178
|
+
2. per-controller default `self.pricing_plans_redirect_on_blocked_limit`
|
|
179
|
+
3. global `config.redirect_on_blocked_limit`
|
|
180
|
+
4. `pricing_path` helper if available
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
## Set up a redirect when a limit is reached
|
|
184
|
+
|
|
185
|
+
You can optionally configure a global default redirect:
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
# config/initializers/pricing_plans.rb
|
|
189
|
+
PricingPlans.configure do |config|
|
|
190
|
+
config.redirect_on_blocked_limit = :pricing_path # or "/pricing" or ->(result) { pricing_path }
|
|
191
|
+
end
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Or a per-controller default (optional):
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
class ApplicationController < ActionController::Base
|
|
198
|
+
self.pricing_plans_redirect_on_blocked_limit = :pricing_path
|
|
199
|
+
end
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Redirect resolution priority:
|
|
203
|
+
1) `redirect_to:` option on the call
|
|
204
|
+
2) Per-controller `self.pricing_plans_redirect_on_blocked_limit`
|
|
205
|
+
3) Global `config.redirect_on_blocked_limit`
|
|
206
|
+
4) `pricing_path` helper (if present)
|
|
207
|
+
5) Fallback: render 403 (HTML or JSON)
|
|
208
|
+
|
|
209
|
+
Per-controller default accepts:
|
|
210
|
+
- Symbol: helper method name (e.g., `:pricing_path`)
|
|
211
|
+
- String: path or URL (e.g., `"/pricing"`)
|
|
212
|
+
- Proc: `->(result) { pricing_path }` (instance-exec'd in the controller)
|
|
213
|
+
|
|
214
|
+
Global default accepts the same types. The Proc receives the `Result` so you can branch on `limit_key`, etc.
|
|
215
|
+
|
|
216
|
+
Recommended patterns:
|
|
217
|
+
- Set a single global default in your initializer.
|
|
218
|
+
- Override per controller only if UX differs for a section.
|
|
219
|
+
- Use the dynamic helpers as symbols in before_action for clarity:
|
|
220
|
+
```ruby
|
|
221
|
+
before_action :enforce_projects_limit!, only: :create
|
|
222
|
+
before_action :enforce_api_access!
|
|
223
|
+
```
|