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,318 @@
|
|
|
1
|
+
# Model helpers and methods
|
|
2
|
+
|
|
3
|
+
## Define your `PlanOwner` class
|
|
4
|
+
|
|
5
|
+
Your `PlanOwner` class is the class on which plan limits are enforced.
|
|
6
|
+
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
To define your `PlanOwner` class, just add the model mixin:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
class User < ApplicationRecord
|
|
13
|
+
include PricingPlans::PlanOwner
|
|
14
|
+
end
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
By adding the `PricingPlans::PlanOwner` mixin to a model, you automatically get all the features described below.
|
|
18
|
+
|
|
19
|
+
## Link plan limits to your `PlanOwner` model
|
|
20
|
+
|
|
21
|
+
Now you can link any `has_many` relationships in this model to `limits` defined in your `pricing_plans.rb`
|
|
22
|
+
|
|
23
|
+
For example, if you defined a `:projects` limit like this:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
plan :pro do
|
|
27
|
+
limits :projects, to: 5
|
|
28
|
+
end
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Then you can link the `:projects` limit to any `has_many` relationship on the `PlanOwner` model (`User`, in this example):
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
class User < ApplicationRecord
|
|
35
|
+
include PricingPlans::PlanOwner
|
|
36
|
+
|
|
37
|
+
has_many :projects, limited_by_pricing_plans: true
|
|
38
|
+
end
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
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:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
class User < ApplicationRecord
|
|
45
|
+
include PricingPlans::PlanOwner
|
|
46
|
+
|
|
47
|
+
has_many :custom_projects, limited_by_pricing_plans: { limit_key: :projects }
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
In general, you can omit the limit key when it can be inferred from the model (e.g., `Project` → `:projects`).
|
|
52
|
+
|
|
53
|
+
`limited_by_pricing_plans` plays nicely with every other ActiveRecord validation you may have in your relationship:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
class User < ApplicationRecord
|
|
57
|
+
include PricingPlans::PlanOwner
|
|
58
|
+
|
|
59
|
+
has_many :projects, limited_by_pricing_plans: true, dependent: :destroy
|
|
60
|
+
end
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
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:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
class User < ApplicationRecord
|
|
67
|
+
include PricingPlans::PlanOwner
|
|
68
|
+
|
|
69
|
+
has_many :projects, limited_by_pricing_plans: { error_after_limit: "Too many projects!" }, dependent: :destroy
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
## Enforce limits in your `PlanOwner` class
|
|
75
|
+
|
|
76
|
+
The `PlanOwner` class (the class to which you add the `include PricingPlans::PlanOwner` mixin) automatically gains these helpers to check limits:
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
# Check limits for a relationship
|
|
80
|
+
user.plan_limit_remaining(:projects) # => integer or :unlimited
|
|
81
|
+
user.plan_limit_percent_used(:projects) # => Float percent
|
|
82
|
+
user.within_plan_limits?(:projects, by: 1) # => true/false
|
|
83
|
+
|
|
84
|
+
# Grace helpers
|
|
85
|
+
user.grace_active_for?(:projects) # => true/false
|
|
86
|
+
user.grace_ends_at_for(:projects) # => Time or nil
|
|
87
|
+
user.grace_remaining_seconds_for(:projects) # => Integer seconds
|
|
88
|
+
user.grace_remaining_days_for(:projects) # => Integer days (ceil)
|
|
89
|
+
user.plan_blocked_for?(:projects) # => true/false (considering after_limit policy)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
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:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
# Check limits (per `limits` key)
|
|
96
|
+
user.projects_remaining
|
|
97
|
+
user.projects_percent_used
|
|
98
|
+
user.projects_within_plan_limits?
|
|
99
|
+
|
|
100
|
+
# Grace helpers (per `limits` key)
|
|
101
|
+
user.projects_grace_active?
|
|
102
|
+
user.projects_grace_ends_at
|
|
103
|
+
user.projects_blocked?
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
These methods are dynamically generated for every `has_many :<limit_key>`, like this:
|
|
107
|
+
- `<limit_key>_remaining`
|
|
108
|
+
- `<limit_key>_percent_used`
|
|
109
|
+
- `<limit_key>_within_plan_limits?` (optionally: `<limit_key>_within_plan_limits?(by: 1)`)
|
|
110
|
+
- `<limit_key>_grace_active?`
|
|
111
|
+
- `<limit_key>_grace_ends_at`
|
|
112
|
+
- `<limit_key>_blocked?`
|
|
113
|
+
|
|
114
|
+
If you want to get an aggregate of graces across multiple keys instead of checking them individually:
|
|
115
|
+
```ruby
|
|
116
|
+
# Aggregates across keys
|
|
117
|
+
user.any_grace_active_for?(:products, :activations)
|
|
118
|
+
user.earliest_grace_ends_at_for(:products, :activations)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Gate features in your `PlanOwner` class
|
|
122
|
+
|
|
123
|
+
You can also check for feature flags like this:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
user.plan_allows?(:api_access) # => true/false
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Of course, there's also dynamic syntactic sugar of the form `plan_allows_<feature_key>?`, like this:
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
user.plan_allows_api_access?
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Usage and limits status
|
|
136
|
+
|
|
137
|
+
Checking the current usage with respect to plan limits comes in handy, especially when [building views](/docs/04-views.md). The following methods are useful to build warning / alert snippets, upgrade prompts, usage trackers, etc.
|
|
138
|
+
|
|
139
|
+
### `limit`: Check a single limit
|
|
140
|
+
|
|
141
|
+
You can check the status of a single limit with `user.limit(:projects)`
|
|
142
|
+
|
|
143
|
+
This always returns a single `StatusItem`, which represents one status item for a limit. For example, output for `user.limit(:projects)`:
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
#<struct
|
|
147
|
+
key=:projects,
|
|
148
|
+
human_key="projects",
|
|
149
|
+
current=1,
|
|
150
|
+
allowed=1,
|
|
151
|
+
percent_used=100.0,
|
|
152
|
+
grace_active=false,
|
|
153
|
+
grace_ends_at=nil,
|
|
154
|
+
blocked=true,
|
|
155
|
+
per=false,
|
|
156
|
+
severity=:at_limit,
|
|
157
|
+
severity_level=2,
|
|
158
|
+
message="You’ve reached your limit for projects (1/1). Upgrade your plan to unlock more.",
|
|
159
|
+
overage=0,
|
|
160
|
+
configured=true,
|
|
161
|
+
unlimited=false,
|
|
162
|
+
remaining=0,
|
|
163
|
+
after_limit=:block_usage,
|
|
164
|
+
:attention?=true,
|
|
165
|
+
:next_creation_blocked?=true,
|
|
166
|
+
warn_thresholds=[0.6, 0.8, 0.95],
|
|
167
|
+
next_warn_percent=nil,
|
|
168
|
+
period_start=nil,
|
|
169
|
+
period_end=nil,
|
|
170
|
+
period_seconds_remaining=nil
|
|
171
|
+
>
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
#### The `StatusItem` object
|
|
175
|
+
|
|
176
|
+
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.
|
|
177
|
+
|
|
178
|
+
Severity order: `:blocked` > `:grace` > `:at_limit` > `:warning` > `:ok`
|
|
179
|
+
|
|
180
|
+
Their corresponding `severity_level` are: `4`, `3`, `2`, `1`, `0`; respectively.
|
|
181
|
+
|
|
182
|
+
Each severity comes with a default **title**:
|
|
183
|
+
- `blocked`: "Cannot create more resources"
|
|
184
|
+
- `grace`: "Limit Exceeded (Grace Active)"
|
|
185
|
+
- `at_limit`: "At Limit"
|
|
186
|
+
- `warning`: "Approaching Limit"
|
|
187
|
+
- `ok`: `nil`
|
|
188
|
+
|
|
189
|
+
Each severity Messages come from your `config.message_builder` in [`pricing_plans.rb`](/docs/01-define-pricing-plans.md) when present; otherwise we provide sensible defaults:
|
|
190
|
+
- `blocked`: "Cannot create more <key> on your current plan."
|
|
191
|
+
- `grace`: "Over the <key> limit, grace active until <date>."
|
|
192
|
+
- `at_limit`: "You are at <current>/<limit> <key>. The next will exceed your plan."
|
|
193
|
+
- `warning`: "You have used <current>/<limit> <key>."
|
|
194
|
+
- `ok`: `nil`
|
|
195
|
+
|
|
196
|
+
### `limits`: Get the status of all limits
|
|
197
|
+
|
|
198
|
+
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.
|
|
199
|
+
|
|
200
|
+
Sample output:
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
user.limits
|
|
204
|
+
|
|
205
|
+
# => [
|
|
206
|
+
# #<struct key=:limit_1...>,
|
|
207
|
+
# #<struct key=:limit_2...>,
|
|
208
|
+
# #<struct key=:limit_3...>
|
|
209
|
+
# ]
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Of course, prefer `user.limit(:key)` (singular, one argument) when you only need a the status of a single limit.
|
|
213
|
+
|
|
214
|
+
You can also filter which limits you get status items for, by passing their limits keys as arguments:
|
|
215
|
+
|
|
216
|
+
```ruby
|
|
217
|
+
user.limits(:projects, :posts)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
### `limits_overview`: Get a summary of all limits
|
|
222
|
+
|
|
223
|
+
`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.
|
|
224
|
+
|
|
225
|
+
`limits_overview` returns a JSON containing:
|
|
226
|
+
- `severity`: highest severity out of all limits
|
|
227
|
+
- `severity_level` corresponding severity level
|
|
228
|
+
- `title`: overall severity title
|
|
229
|
+
- `message`: overall severity message
|
|
230
|
+
- `attention?`: whether overall limits require user attention or not
|
|
231
|
+
- `keys`: array of all computed limits keys
|
|
232
|
+
- `highest_keys`: array of limits keys with the highest severity
|
|
233
|
+
- `highest_limits`: array of `StatusItem`
|
|
234
|
+
- `keys_sentence`: limit keys requiring attention, in a readable sentence
|
|
235
|
+
|
|
236
|
+
For example:
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
user.limits_overview
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Would output:
|
|
243
|
+
|
|
244
|
+
```ruby
|
|
245
|
+
{
|
|
246
|
+
severity: :at_limit,
|
|
247
|
+
severity_level: 2,
|
|
248
|
+
title: "At your plan limit",
|
|
249
|
+
message: "You have reached your plan limit for products.",
|
|
250
|
+
attention?: true,
|
|
251
|
+
keys: [:products, :licenses, :activations],
|
|
252
|
+
highest_keys: [:products],
|
|
253
|
+
highest_limits: [
|
|
254
|
+
#<struct key=:projects...>
|
|
255
|
+
],
|
|
256
|
+
keys_sentence: "products",
|
|
257
|
+
noun: "plan limit",
|
|
258
|
+
has_have: "has",
|
|
259
|
+
cta_text: "View Plans",
|
|
260
|
+
cta_url: nil
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Of course, you can also pass limit keys as arguments to filter the output, like: `user.limits_overview(:projects, :posts)`
|
|
265
|
+
|
|
266
|
+
### Limits aggregates
|
|
267
|
+
|
|
268
|
+
If you only want to get the overall severity of message of all keys, you can do:
|
|
269
|
+
|
|
270
|
+
```ruby
|
|
271
|
+
user.limits_severity(:projects, :posts) # => :ok | :warning | :at_limit | :grace | :blocked
|
|
272
|
+
user.limits_message(:projects, :posts) # => String (combined human message string) or `nil`
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
Additional per-limit checks:
|
|
276
|
+
|
|
277
|
+
```ruby
|
|
278
|
+
user.limit_overage(:projects) # => Integer (0 if within)
|
|
279
|
+
user.limit_alert(:projects) # => { visible?: true/false, severity:, title:, message:, overage:, cta_text:, cta_url: }
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
You also get these handy helpers:
|
|
283
|
+
|
|
284
|
+
```ruby
|
|
285
|
+
user.attention_required_for_limit?(:projects) # => true | false` (alias for any of warning/grace/blocked)
|
|
286
|
+
user.approaching_limit?(:projects, at: 0.9) # => true | false` (uses highest `warn_at` if `at` omitted)
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
You can also use the top-level equivalents if you prefer: `PricingPlans.severity_for(user, :projects)` and friends.
|
|
290
|
+
|
|
291
|
+
## Other helpers and methods
|
|
292
|
+
|
|
293
|
+
### Check and override plans
|
|
294
|
+
|
|
295
|
+
You can also check and override the current pricing plan for any user, which comes handy as an admin:
|
|
296
|
+
```ruby
|
|
297
|
+
user.current_pricing_plan # => PricingPlans::Plan
|
|
298
|
+
user.assign_pricing_plan!(:pro) # manual assignment override
|
|
299
|
+
user.remove_pricing_plan! # remove manual override (fallback to default)
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
### Misc
|
|
303
|
+
|
|
304
|
+
```ruby
|
|
305
|
+
user.on_free_plan? # => true/false
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
### `pay` integration
|
|
309
|
+
|
|
310
|
+
And finally, you get very thin convenient wrappers if you're using the `pay` gem:
|
|
311
|
+
```ruby
|
|
312
|
+
# Pay (Stripe) convenience (returns false/nil when Pay is absent)
|
|
313
|
+
# Note: this is billing-facing state, distinct from our in-app
|
|
314
|
+
# enforcement grace which is tracked per-limit.
|
|
315
|
+
user.pay_subscription_active? # => true/false
|
|
316
|
+
user.pay_on_trial? # => true/false
|
|
317
|
+
user.pay_on_grace_period? # => true/false
|
|
318
|
+
```
|
data/docs/04-views.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Views: pricing pages, paywalls, usage indicators, conditional UI
|
|
2
|
+
|
|
3
|
+
Since `pricing_plans` is your single source of truth for pricing plans, you can query it at any time and get easy-to-display information to create views like pricing pages and paywalls very easily.
|
|
4
|
+
|
|
5
|
+
`pricing_plans` is UI-agnostic, meaning we don't ship any UI components with the gem, but we provide you with all the data you need to build UI components easily. You fully control the HTML/CSS, while `pricing_plans` gives you clear, composable data.
|
|
6
|
+
|
|
7
|
+
## Display all plans
|
|
8
|
+
|
|
9
|
+
`PricingPlans.plans` returns an array of `PricingPlans::Plan` objects containing all your plans defined in `pricing_plans.rb`
|
|
10
|
+
|
|
11
|
+
Each `PricingPlans::Plan` responds to:
|
|
12
|
+
- `plan.free?`
|
|
13
|
+
- `plan.highlighted?`
|
|
14
|
+
- `plan.popular?` (alias of `highlighted?`)
|
|
15
|
+
- `plan.name`
|
|
16
|
+
- `plan.description`
|
|
17
|
+
- `plan.bullets` → Array of strings
|
|
18
|
+
- `plan.price_label` → The `price` or `price_string` you've defined for the plan. If `stripe_price` is set and the Stripe gem is available, it auto-fetches the live price from Stripe. You can override or disable this.
|
|
19
|
+
- `plan.cta_text`
|
|
20
|
+
- `plan.cta_url`
|
|
21
|
+
|
|
22
|
+
### Example: build a pricing page
|
|
23
|
+
|
|
24
|
+
Building a pricing table is as easy as iterating over all `Plans` and displaying their info:
|
|
25
|
+
|
|
26
|
+
```erb
|
|
27
|
+
<% PricingPlans.plans.each do |plan| %>
|
|
28
|
+
<article class="card <%= 'is-current' if plan == current_user.current_pricing_plan %> <%= 'is-popular' if plan.highlighted? %>">
|
|
29
|
+
<h3><%= plan.name %></h3>
|
|
30
|
+
<p><%= plan.description %></p>
|
|
31
|
+
<ul>
|
|
32
|
+
<% plan.bullets.each do |b| %>
|
|
33
|
+
<li><%= b %></li>
|
|
34
|
+
<% end %>
|
|
35
|
+
</ul>
|
|
36
|
+
<div class="price"><%= plan.price_label %></div>
|
|
37
|
+
<% if (url = plan.cta_url) %>
|
|
38
|
+
<%= link_to plan.cta_text, url, class: 'btn' %>
|
|
39
|
+
<% else %>
|
|
40
|
+
<%= button_tag plan.cta_text, class: 'btn', disabled: true %>
|
|
41
|
+
<% end %>
|
|
42
|
+
</article>
|
|
43
|
+
<% end %>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
> [!TIP]
|
|
47
|
+
> If you need more detail for the price (not just `price_label`, but also if it's monthly, yearly, etc.) check out the [Semantic Pricing API](/docs/05-semantic-pricing.md).
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+

|
|
51
|
+
|
|
52
|
+
## Get the highlighted plan
|
|
53
|
+
|
|
54
|
+
You get helpers to access the highlighted plan:
|
|
55
|
+
- `PricingPlans.highlighted_plan`
|
|
56
|
+
- `PricingPlans.highlighted_plan_key`
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
## Get the next plan suggestion
|
|
60
|
+
- `PricingPlans.suggest_next_plan_for(plan_owner, keys: [:projects, ...])`
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
## Conditional UI
|
|
64
|
+
|
|
65
|
+

|
|
66
|
+
|
|
67
|
+
We can leverage the [model methods and helpers](/docs/03-model-helpers.md) to build conditional UIs depending on pricing plan limits:
|
|
68
|
+
|
|
69
|
+
### Example: disable buttons when outside plan limits
|
|
70
|
+
|
|
71
|
+
You can gate object creation by enabling or disabling create buttons depending on limits usage:
|
|
72
|
+
|
|
73
|
+
```erb
|
|
74
|
+
<% if current_organization.within_plan_limits?(:projects) %>
|
|
75
|
+
<!-- Show enabled "create new project" button -->
|
|
76
|
+
<% else %>
|
|
77
|
+
<!-- Disabled button + hint -->
|
|
78
|
+
<% end %>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Tip: you could also use `plan_allows?(:api_access)` to build feature-gating UIs.
|
|
82
|
+
|
|
83
|
+
### Example: block an entire element if not in plan
|
|
84
|
+
|
|
85
|
+
```erb
|
|
86
|
+
<% if current_organization.plan_blocked_for?(:projects) %>
|
|
87
|
+
<!-- Disabled UI; creation is blocked by the plan -->
|
|
88
|
+
<% end %>
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Alerts and usage
|
|
92
|
+
|
|
93
|
+

|
|
94
|
+
|
|
95
|
+
### Example: display an alert for a limit
|
|
96
|
+
|
|
97
|
+
```erb
|
|
98
|
+
<% if current_organization.attention_required_for_limit?(:projects) %>
|
|
99
|
+
<%= render "shared/plan_limit_alert", plan_owner: current_organization, key: :projects %>
|
|
100
|
+
<% end %>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Example: plan usage summary
|
|
104
|
+
|
|
105
|
+
```erb
|
|
106
|
+
<% s = current_organization.limit(:projects) %>
|
|
107
|
+
<div><%= s.key.to_s.humanize %>: <%= s.current %> / <%= s.allowed %> (<%= s.percent_used.round(1) %>%)</div>
|
|
108
|
+
<% if s.blocked %>
|
|
109
|
+
<div class="notice notice--error">Creation blocked due to plan limits</div>
|
|
110
|
+
<% elsif s.grace_active %>
|
|
111
|
+
<div class="notice notice--warning">Over limit — grace active until <%= s.grace_ends_at %></div>
|
|
112
|
+
<% end %>
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Tip: you could also use `plan_limit_remaining(:projects)` and `plan_limit_percent_used(:projects)` to show current usage.
|
|
116
|
+
|
|
117
|
+

|
|
118
|
+
|
|
119
|
+
## Message customization
|
|
120
|
+
|
|
121
|
+
- You can override copy globally via `config.message_builder` in [`pricing_plans.rb`](/docs/01-define-pricing-plans.md), which is used across limit checks and features. Suggested signature: `(context:, **kwargs) -> string` with contexts `:over_limit`, `:grace`, `:feature_denied`, and `:overage_report`.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# Semantic pricing
|
|
2
|
+
|
|
3
|
+
Building delightful pricing UIs usually needs structured price parts (currency, amount, interval) and both monthly/yearly data. `pricing_plans` ships a semantic, UI‑agnostic API so you don't have to parse price strings in your app.
|
|
4
|
+
|
|
5
|
+
## Value object: `PricingPlans::PriceComponents`
|
|
6
|
+
|
|
7
|
+
Structure returned by the helpers below:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
PricingPlans::PriceComponents = Struct.new(
|
|
11
|
+
:present?, # boolean: price is numeric?
|
|
12
|
+
:currency, # string currency symbol, e.g. "$", "€"
|
|
13
|
+
:amount, # string whole amount, e.g. "29"
|
|
14
|
+
:amount_cents, # integer cents, e.g. 2900
|
|
15
|
+
:interval, # :month | :year
|
|
16
|
+
:label, # friendly label, e.g. "$29/mo" or "Contact"
|
|
17
|
+
:monthly_equivalent_cents, # integer; = amount for monthly, or yearly/12 rounded
|
|
18
|
+
keyword_init: true
|
|
19
|
+
)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Semantic pricing helpers
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
plan.price_components(interval: :month) # => PriceComponents
|
|
26
|
+
plan.monthly_price_components # sugar for :month
|
|
27
|
+
plan.yearly_price_components # sugar for :year
|
|
28
|
+
|
|
29
|
+
plan.has_interval_prices? # true if configured/inferred
|
|
30
|
+
plan.has_numeric_price? # true if numeric (price or stripe_price)
|
|
31
|
+
|
|
32
|
+
plan.price_label_for(:month) # "$29/mo" (uses PriceComponents)
|
|
33
|
+
plan.price_label_for(:year) # "$290/yr" or Stripe-derived
|
|
34
|
+
|
|
35
|
+
plan.monthly_price_cents # integer or nil
|
|
36
|
+
plan.yearly_price_cents # integer or nil
|
|
37
|
+
plan.monthly_price_id # Stripe Price ID (when available)
|
|
38
|
+
plan.yearly_price_id
|
|
39
|
+
plan.currency_symbol # "$" or derived from Stripe
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Notes:
|
|
43
|
+
|
|
44
|
+
- If `stripe_price` is configured, we derive cents, currency, and interval from the Stripe Price (and cache it).
|
|
45
|
+
- If `price 0` (free), we return components with `present? == true`, amount 0 and the configured default currency symbol.
|
|
46
|
+
- If only `price_string` is set (e.g., "Contact us"), components return `present? == false`, `label == price_string`.
|
|
47
|
+
|
|
48
|
+
## Pure-data view models
|
|
49
|
+
|
|
50
|
+
- Per‑plan:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
plan.to_view_model
|
|
54
|
+
# => {
|
|
55
|
+
# id:, key:, name:, description:, features:, highlighted:, default:, free:,
|
|
56
|
+
# currency:, monthly_price_cents:, yearly_price_cents:,
|
|
57
|
+
# monthly_price_id:, yearly_price_id:,
|
|
58
|
+
# price_label:, price_string:, limits: { ... }
|
|
59
|
+
# }
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
- All plans (preserves `PricingPlans.plans` order):
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
PricingPlans.view_models # => Array<Hash>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## UI helpers
|
|
69
|
+
|
|
70
|
+
We include data‑only helpers into ActionView.
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
pricing_plan_ui_data(plan)
|
|
74
|
+
# => {
|
|
75
|
+
# monthly_price:, yearly_price:,
|
|
76
|
+
# monthly_price_cents:, yearly_price_cents:,
|
|
77
|
+
# monthly_price_id:, yearly_price_id:,
|
|
78
|
+
# free:, label:
|
|
79
|
+
# }
|
|
80
|
+
|
|
81
|
+
pricing_plan_cta(plan, plan_owner: nil, context: :marketing, current_plan: nil)
|
|
82
|
+
# => { text:, url:, method: :get, disabled:, reason: }
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`pricing_plan_cta` disables the button for the current plan (text: "Current Plan"). You can add a downgrade policy (see configuration) to surface `reason` in your UI.
|
|
86
|
+
|
|
87
|
+
## Plan comparison ergonomics (for CTAs)
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
plan.current_for?(current_plan) # boolean
|
|
91
|
+
plan.upgrade_from?(current_plan) # boolean
|
|
92
|
+
plan.downgrade_from?(current_plan) # boolean
|
|
93
|
+
plan.downgrade_blocked_reason(from: current_plan, plan_owner: org) # string | nil
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Stripe lookups and caching
|
|
97
|
+
|
|
98
|
+
- We fetch Stripe Price objects when `stripe_price` is present.
|
|
99
|
+
- Caching is supported via `config.price_cache` (defaults to `Rails.cache` when available).
|
|
100
|
+
- TTL controlled by `config.price_cache_ttl` (default 10 minutes).
|
|
101
|
+
|
|
102
|
+
Example initializer snippet:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
PricingPlans.configure do |config|
|
|
106
|
+
config.price_cache = Rails.cache
|
|
107
|
+
config.price_cache_ttl = 10.minutes
|
|
108
|
+
end
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Configuration for pricing semantics
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
PricingPlans.configure do |config|
|
|
115
|
+
# Currency symbol when Stripe is absent
|
|
116
|
+
config.default_currency_symbol = "$"
|
|
117
|
+
|
|
118
|
+
# Cache & TTL for Stripe Price lookups
|
|
119
|
+
config.price_cache = Rails.cache
|
|
120
|
+
config.price_cache_ttl = 10.minutes
|
|
121
|
+
|
|
122
|
+
# Optional hook to fully customize components
|
|
123
|
+
# Signature: ->(plan, interval) { PricingPlans::PriceComponents | nil }
|
|
124
|
+
config.price_components_resolver = ->(plan, interval) { nil }
|
|
125
|
+
|
|
126
|
+
# Optional free copy used by some data helpers
|
|
127
|
+
config.free_price_caption = "Forever free"
|
|
128
|
+
|
|
129
|
+
# Default UI interval for toggles
|
|
130
|
+
config.interval_default_for_ui = :month # or :year
|
|
131
|
+
|
|
132
|
+
# Downgrade policy used by CTA ergonomics
|
|
133
|
+
# Signature: ->(from:, to:, plan_owner:) { [allowed_boolean, reason_or_nil] }
|
|
134
|
+
config.downgrade_policy = ->(from:, to:, plan_owner:) { [true, nil] }
|
|
135
|
+
end
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Stripe price labels in `plan.price_label`
|
|
139
|
+
|
|
140
|
+
By default, if a plan has `stripe_price` configured and the `stripe` gem is present, we auto-fetch the Stripe Price and render a friendly label (e.g., `$29/mo`). This mirrors Pay’s use of Stripe Prices.
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
To disable auto-fetching globally:
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
PricingPlans.configure do |config|
|
|
147
|
+
config.auto_price_labels_from_processor = false
|
|
148
|
+
end
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
To fully customize rendering (e.g., caching, locale):
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
PricingPlans.configure do |config|
|
|
155
|
+
config.price_label_resolver = ->(plan) do
|
|
156
|
+
# Build and return a string like "$29/mo" based on your own logic
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
```
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Using `pricing_plans` with `pay` and/or `usage_credits`
|
|
2
|
+
|
|
3
|
+
`pricing_plans` is designed to work seamlessly with other complementary popular gems like `pay` (to handle actual subscriptions and payments), and `usage_credits` (to handle credit-like spending and refills)
|
|
4
|
+
|
|
5
|
+
These gems are related but not overlapping. They're complementary. The boundaries are clear: billing is handled in Pay; metering (ledger-like) in usage_credits.
|
|
6
|
+
|
|
7
|
+
The integration with `pay` should be seamless and is documented throughout the entire docs; however, here's a brief note about using `usage_credits` alongside `pricing_plans`.
|
|
8
|
+
|
|
9
|
+
## Using `pricing_plans` with the `usage_credits` gem
|
|
10
|
+
|
|
11
|
+
In the SaaS world, pricing plans and usage credits are related in so far credits are usually a part of a pricing plan. A plan would give you, say, 100 credits a month along other features, and users would find that information usually documented in the pricing table itself.
|
|
12
|
+
|
|
13
|
+
However, for the purposes of this gem, pricing plans and usage credits are two very distinct things.
|
|
14
|
+
|
|
15
|
+
If you want to add credits to your app, you should install and configure the [usage_credits](https://github.com/rameerez/usage_credits) gem separately. In the `usage_credits` configuration, you should specify how many credits your users get with each subscription.
|
|
16
|
+
|
|
17
|
+
### The difference between usage credits and per-period plan limits
|
|
18
|
+
|
|
19
|
+
> [!WARNING]
|
|
20
|
+
> Usage credits are not the same as per-period limits.
|
|
21
|
+
|
|
22
|
+
**Usage credits behave like a currency**. Per-period limits are not a currency, and shouldn't be purchaseable.
|
|
23
|
+
|
|
24
|
+
- **Usage credits** are like: "100 image-generation credits a month"
|
|
25
|
+
- **Per-period limits** are like: "Create up to 3 new projects a month"
|
|
26
|
+
|
|
27
|
+
Usage credits can be refilled (buy credit packs, your balance goes up), can be spent (your balance goes down). Per-period limits do not. If you intend to sell credit packs, or if the balance needs to go both up and down, you should implement usage credits, NOT per-period limits.
|
|
28
|
+
|
|
29
|
+
Some other examples of per-period limits: “1 domain change per week”, “2 exports/day”. Those are discrete allowances, not metered workloads. For classic metered workloads (API calls, image generations, tokenized compute), use credits instead.
|
|
30
|
+
|
|
31
|
+
Here's a few rules for a clean separation to help you decide when to use either gem:
|
|
32
|
+
|
|
33
|
+
`pricing_plans` handles:
|
|
34
|
+
- Booleans (feature flags).
|
|
35
|
+
- Persistent caps (max concurrent resources: products, seats, projects at a time).
|
|
36
|
+
- Discrete per-period allowances (e.g., “3 exports / month”), with no overage purchasing.
|
|
37
|
+
|
|
38
|
+
`usage_credits` handles:
|
|
39
|
+
- Metered consumption (API calls, generations, storage GB*hrs, etc.).
|
|
40
|
+
- Included monthly credits via subscription plans.
|
|
41
|
+
- Top-ups and pay-as-you-go.
|
|
42
|
+
- Rollover/expire semantics and the entire ledger.
|
|
43
|
+
|
|
44
|
+
If a dimension is metered and you want to sell overage/top-ups, use credits only. Don’t also define a periodic limit for the same dimension in `pricing_plans`. We’ll actively lint and refuse dual definitions at boot.
|
|
45
|
+
|
|
46
|
+
### How to show `usage_credits` in `pricing_plans`
|
|
47
|
+
|
|
48
|
+
With all that being said, in SaaS users would typically find information about plan credits in the pricing plan table, and because of that, and since `pricing_plans` should be the single source of truth for pricing plans in your Rails app, you should include how many credits your plans give in `pricing_plans.rb`:
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
PricingPlans.configure do |config|
|
|
52
|
+
plan :pro do
|
|
53
|
+
bullets "API access", "100 credits per month"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`pricing_plans` ships some ergonomics to declare and render included credits, and guardrails to keep your configuration coherent when `usage_credits` is present.
|
|
59
|
+
|
|
60
|
+
#### Declare included credits in your plans (single currency)
|
|
61
|
+
|
|
62
|
+
Plans can advertise the total credits included. This is cosmetic for pricing UI; `usage_credits` remains the source of truth for fulfillment and spending:
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
PricingPlans.configure do |config|
|
|
66
|
+
config.plan :free do
|
|
67
|
+
price 0
|
|
68
|
+
includes_credits 100
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
config.plan :pro do
|
|
72
|
+
price 29
|
|
73
|
+
includes_credits 5_000
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
When you’re composing your UI, you can read credits via `plan.credits_included`.
|
|
79
|
+
|
|
80
|
+
> [!IMPORTANT]
|
|
81
|
+
> You need to keep defining operations and subscription fulfillment in your `usage_credits` initializer, declaring it in pricing_plans is purely cosmetic and for ergonomics to render pricing tables.
|
|
82
|
+
|
|
83
|
+
#### Guardrails when `usage_credits` is installed
|
|
84
|
+
|
|
85
|
+
When the `usage_credits` gem is present, we lint your configuration at boot to prevent ambiguous setups:
|
|
86
|
+
|
|
87
|
+
Collisions between credits and per‑period plan limits are disallowed: you cannot define a per‑period limit for a key that is also a `usage_credits` operation (e.g., `limits :api_calls, to: 50, per: :month`). If a dimension is metered, use credits only.
|
|
88
|
+
|
|
89
|
+
This enforces a clean separation:
|
|
90
|
+
|
|
91
|
+
- Use `usage_credits` for metered workloads you may wish to top‑up or charge PAYG for.
|
|
92
|
+
- Use `pricing_plans` limits for discrete allowances and feature flags (things that don’t behave like a currency).
|
|
93
|
+
|
|
94
|
+
#### No runtime coupling; single source of truth
|
|
95
|
+
|
|
96
|
+
`pricing_plans` does not spend or refill credits — that’s owned by `usage_credits`.
|
|
97
|
+
|
|
98
|
+
- Keep using `@user.spend_credits_on(:operation, ...)`, subscription fulfillment, and credit packs in `usage_credits`.
|
|
99
|
+
- Treat `includes_credits` here as pricing UI copy only. The single source of truth for operations, costs, fulfillment cadence, rollover/expire, and balances lives in `usage_credits`.
|
|
Binary file
|