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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/settings.local.json +16 -0
  3. data/.rubocop.yml +137 -0
  4. data/CHANGELOG.md +83 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +241 -0
  7. data/Rakefile +15 -0
  8. data/docs/01-define-pricing-plans.md +372 -0
  9. data/docs/02-controller-helpers.md +223 -0
  10. data/docs/03-model-helpers.md +318 -0
  11. data/docs/04-views.md +121 -0
  12. data/docs/05-semantic-pricing.md +159 -0
  13. data/docs/06-gem-compatibility.md +99 -0
  14. data/docs/images/pricing_plans_ruby_rails_gem_pricing_table.jpg +0 -0
  15. data/docs/images/pricing_plans_ruby_rails_gem_usage_alert_upgrade.jpg +0 -0
  16. data/docs/images/pricing_plans_ruby_rails_gem_usage_meter.jpg +0 -0
  17. data/docs/images/product_creation_blocked.jpg +0 -0
  18. data/lib/generators/pricing_plans/install/install_generator.rb +42 -0
  19. data/lib/generators/pricing_plans/install/templates/create_pricing_plans_tables.rb.erb +91 -0
  20. data/lib/generators/pricing_plans/install/templates/initializer.rb +100 -0
  21. data/lib/pricing_plans/association_limit_registry.rb +45 -0
  22. data/lib/pricing_plans/configuration.rb +189 -0
  23. data/lib/pricing_plans/controller_guards.rb +574 -0
  24. data/lib/pricing_plans/controller_rescues.rb +115 -0
  25. data/lib/pricing_plans/dsl.rb +44 -0
  26. data/lib/pricing_plans/engine.rb +69 -0
  27. data/lib/pricing_plans/grace_manager.rb +227 -0
  28. data/lib/pricing_plans/integer_refinements.rb +48 -0
  29. data/lib/pricing_plans/job_guards.rb +24 -0
  30. data/lib/pricing_plans/limit_checker.rb +157 -0
  31. data/lib/pricing_plans/limitable.rb +286 -0
  32. data/lib/pricing_plans/models/assignment.rb +55 -0
  33. data/lib/pricing_plans/models/enforcement_state.rb +45 -0
  34. data/lib/pricing_plans/models/usage.rb +51 -0
  35. data/lib/pricing_plans/overage_reporter.rb +77 -0
  36. data/lib/pricing_plans/pay_support.rb +85 -0
  37. data/lib/pricing_plans/period_calculator.rb +183 -0
  38. data/lib/pricing_plans/plan.rb +653 -0
  39. data/lib/pricing_plans/plan_owner.rb +287 -0
  40. data/lib/pricing_plans/plan_resolver.rb +85 -0
  41. data/lib/pricing_plans/price_components.rb +16 -0
  42. data/lib/pricing_plans/registry.rb +182 -0
  43. data/lib/pricing_plans/result.rb +109 -0
  44. data/lib/pricing_plans/version.rb +5 -0
  45. data/lib/pricing_plans/view_helpers.rb +58 -0
  46. data/lib/pricing_plans.rb +645 -0
  47. data/sig/pricing_plans.rbs +4 -0
  48. 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
+ ![pricing_plans Ruby on Rails gem - pricing table features](/docs/images/pricing_plans_ruby_rails_gem_pricing_table.jpg)
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
+ ![pricing_plans Ruby gem - conditional UI](/docs/images/product_creation_blocked.jpg)
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
+ ![pricing_plans Ruby on Rails gem - pricing plan upgrade prompt](/docs/images/pricing_plans_ruby_rails_gem_usage_alert_upgrade.jpg)
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
+ ![pricing_plans Ruby on Rails gem - pricing plan usage meter](/docs/images/pricing_plans_ruby_rails_gem_usage_meter.jpg)
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`.