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,574 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PricingPlans
|
|
4
|
+
module ControllerGuards
|
|
5
|
+
extend self
|
|
6
|
+
|
|
7
|
+
# When included into a controller, provide dynamic helpers and callbacks
|
|
8
|
+
def self.included(base)
|
|
9
|
+
if base.respond_to?(:class_attribute)
|
|
10
|
+
base.class_attribute :pricing_plans_plan_owner_method, instance_accessor: false, default: nil
|
|
11
|
+
base.class_attribute :pricing_plans_plan_owner_proc, instance_accessor: false, default: nil
|
|
12
|
+
# Optional per-controller default redirect target when a limit blocks
|
|
13
|
+
# Accepts the same types as the global configuration: Symbol | String | Proc
|
|
14
|
+
base.class_attribute :pricing_plans_redirect_on_blocked_limit, instance_accessor: false, default: nil
|
|
15
|
+
end
|
|
16
|
+
# Fallback storage on eigenclass for environments without class_attribute
|
|
17
|
+
if !base.respond_to?(:pricing_plans_plan_owner_proc) && base.respond_to?(:singleton_class)
|
|
18
|
+
base.singleton_class.send(:attr_accessor, :_pricing_plans_plan_owner_proc)
|
|
19
|
+
end
|
|
20
|
+
if !base.respond_to?(:pricing_plans_redirect_on_blocked_limit) && base.respond_to?(:singleton_class)
|
|
21
|
+
base.singleton_class.send(:attr_accessor, :_pricing_plans_redirect_on_blocked_limit)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Provide portable class-level API for redirect default regardless of class_attribute availability
|
|
25
|
+
if base.respond_to?(:define_singleton_method)
|
|
26
|
+
unless base.respond_to?(:pricing_plans_redirect_on_blocked_limit=)
|
|
27
|
+
base.define_singleton_method(:pricing_plans_redirect_on_blocked_limit=) do |value|
|
|
28
|
+
if respond_to?(:pricing_plans_redirect_on_blocked_limit)
|
|
29
|
+
self.pricing_plans_redirect_on_blocked_limit = value
|
|
30
|
+
elsif respond_to?(:_pricing_plans_redirect_on_blocked_limit=)
|
|
31
|
+
self._pricing_plans_redirect_on_blocked_limit = value
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
unless base.respond_to?(:pricing_plans_redirect_on_blocked_limit)
|
|
36
|
+
base.define_singleton_method(:pricing_plans_redirect_on_blocked_limit) do
|
|
37
|
+
if respond_to?(:_pricing_plans_redirect_on_blocked_limit)
|
|
38
|
+
self._pricing_plans_redirect_on_blocked_limit
|
|
39
|
+
else
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
base.define_singleton_method(:pricing_plans_plan_owner) do |method_name = nil, &block|
|
|
47
|
+
if method_name
|
|
48
|
+
self.pricing_plans_plan_owner_method = method_name.to_sym
|
|
49
|
+
self.pricing_plans_plan_owner_proc = nil
|
|
50
|
+
self._pricing_plans_plan_owner_proc = nil if respond_to?(:_pricing_plans_plan_owner_proc)
|
|
51
|
+
elsif block_given?
|
|
52
|
+
# Store the block and use instance_exec at call time
|
|
53
|
+
self.pricing_plans_plan_owner_proc = block
|
|
54
|
+
self._pricing_plans_plan_owner_proc = block if respond_to?(:_pricing_plans_plan_owner_proc)
|
|
55
|
+
self.pricing_plans_plan_owner_method = nil
|
|
56
|
+
else
|
|
57
|
+
self.pricing_plans_plan_owner_method
|
|
58
|
+
end
|
|
59
|
+
end if base.respond_to?(:define_singleton_method)
|
|
60
|
+
|
|
61
|
+
base.define_method(:pricing_plans_plan_owner) do
|
|
62
|
+
# 1) Explicit per-controller configuration wins
|
|
63
|
+
if self.class.respond_to?(:pricing_plans_plan_owner_proc) && self.class.pricing_plans_plan_owner_proc
|
|
64
|
+
return instance_exec(&self.class.pricing_plans_plan_owner_proc)
|
|
65
|
+
elsif self.class.respond_to?(:_pricing_plans_plan_owner_proc) && self.class._pricing_plans_plan_owner_proc
|
|
66
|
+
return instance_exec(&self.class._pricing_plans_plan_owner_proc)
|
|
67
|
+
elsif self.class.respond_to?(:pricing_plans_plan_owner_method) && self.class.pricing_plans_plan_owner_method
|
|
68
|
+
return send(self.class.pricing_plans_plan_owner_method)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# 2) Global controller resolver if configured
|
|
72
|
+
begin
|
|
73
|
+
cfg = PricingPlans.configuration
|
|
74
|
+
if cfg
|
|
75
|
+
if cfg.controller_plan_owner_proc
|
|
76
|
+
return instance_exec(&cfg.controller_plan_owner_proc)
|
|
77
|
+
elsif cfg.controller_plan_owner_method
|
|
78
|
+
meth = cfg.controller_plan_owner_method
|
|
79
|
+
return send(meth) if respond_to?(meth)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
rescue StandardError
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# 3) Infer from configured plan owner class (current_organization, etc.)
|
|
86
|
+
owner_klass = PricingPlans::Registry.plan_owner_class rescue nil
|
|
87
|
+
if owner_klass
|
|
88
|
+
inferred = "current_#{owner_klass.name.underscore}"
|
|
89
|
+
return send(inferred) if respond_to?(inferred)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# 4) Common conventions
|
|
93
|
+
%i[current_organization current_account current_user current_team current_company current_workspace current_tenant].each do |meth|
|
|
94
|
+
return send(meth) if respond_to?(meth)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
raise PricingPlans::ConfigurationError, "Unable to infer plan owner for controller. Set `self.pricing_plans_plan_owner_method = :current_organization` or provide a block via `pricing_plans_plan_owner { ... }`."
|
|
98
|
+
end if base.respond_to?(:define_method)
|
|
99
|
+
|
|
100
|
+
# Dynamic enforce_*! and with_*! helpers for before_action ergonomics
|
|
101
|
+
base.define_method(:method_missing) do |method_name, *args, &block|
|
|
102
|
+
if method_name.to_s =~ /^with_(.+)_limit!$/
|
|
103
|
+
limit_key = Regexp.last_match(1).to_sym
|
|
104
|
+
options = args.first.is_a?(Hash) ? args.first : {}
|
|
105
|
+
owner = if options[:plan_owner]
|
|
106
|
+
options[:plan_owner]
|
|
107
|
+
elsif options[:on]
|
|
108
|
+
resolver = options[:on]
|
|
109
|
+
resolver.is_a?(Symbol) ? send(resolver) : instance_exec(&resolver)
|
|
110
|
+
elsif options[:for]
|
|
111
|
+
resolver = options[:for]
|
|
112
|
+
resolver.is_a?(Symbol) ? send(resolver) : instance_exec(&resolver)
|
|
113
|
+
else
|
|
114
|
+
respond_to?(:pricing_plans_plan_owner) ? pricing_plans_plan_owner : nil
|
|
115
|
+
end
|
|
116
|
+
by = options.key?(:by) ? options[:by] : 1
|
|
117
|
+
allow_system_override = !!options[:allow_system_override]
|
|
118
|
+
redirect_path = options[:redirect_to]
|
|
119
|
+
return with_plan_limit!(limit_key, plan_owner: owner, by: by, allow_system_override: allow_system_override, redirect_to: redirect_path, &block)
|
|
120
|
+
elsif method_name.to_s =~ /^enforce_(.+)_limit!$/
|
|
121
|
+
limit_key = Regexp.last_match(1).to_sym
|
|
122
|
+
options = args.first.is_a?(Hash) ? args.first : {}
|
|
123
|
+
owner = if options[:plan_owner]
|
|
124
|
+
options[:plan_owner]
|
|
125
|
+
elsif options[:on]
|
|
126
|
+
resolver = options[:on]
|
|
127
|
+
resolver.is_a?(Symbol) ? send(resolver) : instance_exec(&resolver)
|
|
128
|
+
elsif options[:for]
|
|
129
|
+
resolver = options[:for]
|
|
130
|
+
resolver.is_a?(Symbol) ? send(resolver) : instance_exec(&resolver)
|
|
131
|
+
else
|
|
132
|
+
respond_to?(:pricing_plans_plan_owner) ? pricing_plans_plan_owner : nil
|
|
133
|
+
end
|
|
134
|
+
by = options.key?(:by) ? options[:by] : 1
|
|
135
|
+
allow_system_override = !!options[:allow_system_override]
|
|
136
|
+
redirect_path = options[:redirect_to]
|
|
137
|
+
enforce_plan_limit!(limit_key, plan_owner: owner, by: by, allow_system_override: allow_system_override, redirect_to: redirect_path)
|
|
138
|
+
return true
|
|
139
|
+
elsif method_name.to_s =~ /^enforce_(.+)!$/
|
|
140
|
+
feature_key = Regexp.last_match(1).to_sym
|
|
141
|
+
options = args.first.is_a?(Hash) ? args.first : {}
|
|
142
|
+
# Support: enforce_feature!(for: :current_organization) and enforce_feature!(plan_owner: obj)
|
|
143
|
+
owner = if options[:plan_owner]
|
|
144
|
+
options[:plan_owner]
|
|
145
|
+
elsif options[:on]
|
|
146
|
+
resolver = options[:on]
|
|
147
|
+
resolver.is_a?(Symbol) ? send(resolver) : instance_exec(&resolver)
|
|
148
|
+
elsif options[:for]
|
|
149
|
+
resolver = options[:for]
|
|
150
|
+
resolver.is_a?(Symbol) ? send(resolver) : instance_exec(&resolver)
|
|
151
|
+
else
|
|
152
|
+
respond_to?(:pricing_plans_plan_owner) ? pricing_plans_plan_owner : nil
|
|
153
|
+
end
|
|
154
|
+
require_feature!(feature_key, plan_owner: owner)
|
|
155
|
+
return true
|
|
156
|
+
end
|
|
157
|
+
super(method_name, *args, &block)
|
|
158
|
+
end if base.respond_to?(:define_method)
|
|
159
|
+
|
|
160
|
+
base.define_method(:respond_to_missing?) do |method_name, include_private = false|
|
|
161
|
+
((method_name.to_s.start_with?("enforce_") || method_name.to_s.start_with?("with_")) && method_name.to_s.end_with?("!")) || super(method_name, include_private)
|
|
162
|
+
end if base.respond_to?(:define_method)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Checks if a given plan_owner object is within the plan limit for a specific key.
|
|
166
|
+
#
|
|
167
|
+
# Usage:
|
|
168
|
+
# result = require_plan_limit!(:projects, plan_owner: current_organization)
|
|
169
|
+
# if result.blocked?
|
|
170
|
+
# # Handle blocked case (e.g., show upgrade prompt)
|
|
171
|
+
# redirect_to upgrade_path, alert: result.message
|
|
172
|
+
# elsif result.warning?
|
|
173
|
+
# flash[:warning] = result.message
|
|
174
|
+
# end
|
|
175
|
+
#
|
|
176
|
+
# Options:
|
|
177
|
+
# - limit_key: The symbol for the limit (e.g., :projects)
|
|
178
|
+
# - plan_owner: The plan_owner object (e.g., current_organization)
|
|
179
|
+
# - by: The number of units to check for (default: 1)
|
|
180
|
+
# - allow_system_override: If true, returns a blocked result but does not enforce the block (default: false)
|
|
181
|
+
#
|
|
182
|
+
# Returns a PricingPlans::Result with state:
|
|
183
|
+
# - :within (allowed)
|
|
184
|
+
# - :warning (allowed, but near limit)
|
|
185
|
+
# - :grace (allowed, but in grace period)
|
|
186
|
+
# - :blocked (not allowed)
|
|
187
|
+
def require_plan_limit!(limit_key, plan_owner: nil, by: 1, allow_system_override: false)
|
|
188
|
+
plan_owner ||= (respond_to?(:pricing_plans_plan_owner) ? pricing_plans_plan_owner : nil)
|
|
189
|
+
plan = PlanResolver.effective_plan_for(plan_owner)
|
|
190
|
+
limit_config = plan&.limit_for(limit_key)
|
|
191
|
+
|
|
192
|
+
# If no limit is configured, allow the action
|
|
193
|
+
unless limit_config
|
|
194
|
+
return Result.within("No limit configured for #{limit_key}")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Check if unlimited
|
|
198
|
+
if limit_config[:to] == :unlimited
|
|
199
|
+
return Result.within("Unlimited #{limit_key}")
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Check current usage and remaining capacity
|
|
203
|
+
current_usage = LimitChecker.current_usage_for(plan_owner, limit_key, limit_config)
|
|
204
|
+
limit_amount = limit_config[:to]
|
|
205
|
+
remaining = limit_amount - current_usage
|
|
206
|
+
|
|
207
|
+
# Check if this action would exceed the limit
|
|
208
|
+
would_exceed = remaining < by
|
|
209
|
+
|
|
210
|
+
if would_exceed
|
|
211
|
+
# Allow trusted flows to bypass hard block while signaling downstream
|
|
212
|
+
if allow_system_override
|
|
213
|
+
metadata = build_metadata(plan_owner, limit_key, current_usage, limit_amount)
|
|
214
|
+
return Result.new(state: :blocked, message: build_over_limit_message(limit_key, current_usage, limit_amount, :blocked), limit_key: limit_key, plan_owner: plan_owner, metadata: metadata.merge(system_override: true))
|
|
215
|
+
end
|
|
216
|
+
# Handle exceeded limit based on after_limit policy
|
|
217
|
+
case limit_config[:after_limit]
|
|
218
|
+
when :just_warn
|
|
219
|
+
handle_warning_only(plan_owner, limit_key, current_usage, limit_amount)
|
|
220
|
+
when :block_usage
|
|
221
|
+
handle_immediate_block(plan_owner, limit_key, current_usage, limit_amount)
|
|
222
|
+
when :grace_then_block
|
|
223
|
+
handle_grace_then_block(plan_owner, limit_key, current_usage, limit_amount, limit_config)
|
|
224
|
+
else
|
|
225
|
+
Result.blocked("Unknown after_limit policy: #{limit_config[:after_limit]}")
|
|
226
|
+
end
|
|
227
|
+
else
|
|
228
|
+
# Within limit - check for warnings
|
|
229
|
+
handle_within_limit(plan_owner, limit_key, current_usage, limit_amount, by)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Rails-y controller ergonomics: enforce, set flash/redirect, and abort the callback chain when blocked.
|
|
234
|
+
# Defaults:
|
|
235
|
+
# - On blocked: redirect_to pricing_path (if available) with alert; else render 403 JSON.
|
|
236
|
+
# - On grace/warning: set flash[:warning] with the human message.
|
|
237
|
+
def enforce_plan_limit!(limit_key, plan_owner: nil, by: 1, allow_system_override: false, redirect_to: nil)
|
|
238
|
+
plan_owner ||= (respond_to?(:pricing_plans_plan_owner) ? pricing_plans_plan_owner : nil)
|
|
239
|
+
result = require_plan_limit!(limit_key, plan_owner: plan_owner, by: by, allow_system_override: allow_system_override)
|
|
240
|
+
|
|
241
|
+
if result.blocked?
|
|
242
|
+
# If caller opted into system override, let them handle downstream
|
|
243
|
+
if allow_system_override && result.metadata && result.metadata[:system_override]
|
|
244
|
+
return true
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Resolve the best redirect target once, and surface it to any handler via metadata
|
|
248
|
+
resolved_target = resolve_redirect_target_for_blocked_limit(result, redirect_to)
|
|
249
|
+
|
|
250
|
+
if respond_to?(:handle_pricing_plans_limit_blocked)
|
|
251
|
+
# Enrich result with redirect target for the centralized handler
|
|
252
|
+
enriched_result = PricingPlans::Result.blocked(
|
|
253
|
+
result.message,
|
|
254
|
+
limit_key: result.limit_key,
|
|
255
|
+
plan_owner: result.plan_owner,
|
|
256
|
+
metadata: (result.metadata || {}).merge(redirect_to: resolved_target)
|
|
257
|
+
)
|
|
258
|
+
handle_pricing_plans_limit_blocked(enriched_result)
|
|
259
|
+
else
|
|
260
|
+
# Local fallback when centralized handler isn't available
|
|
261
|
+
if resolved_target && respond_to?(:redirect_to)
|
|
262
|
+
redirect_to(resolved_target, alert: result.message, status: :see_other)
|
|
263
|
+
elsif respond_to?(:render)
|
|
264
|
+
respond_to?(:request) && request&.format&.json? ? render(json: { error: result.message }, status: :forbidden) : render(plain: result.message, status: :forbidden)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
# Stop the filter chain (for before_action ergonomics)
|
|
268
|
+
throw :abort
|
|
269
|
+
return false
|
|
270
|
+
elsif result.warning? || result.grace?
|
|
271
|
+
if respond_to?(:flash) && flash.respond_to?(:[]=)
|
|
272
|
+
flash[:warning] ||= result.message
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
true
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Controller-focused sugar: run a block within the plan limit context.
|
|
280
|
+
# - If blocked: performs the same redirect/render semantics as enforce_plan_limit! and aborts the callback chain.
|
|
281
|
+
# - If warning/grace: sets flash[:warning] and yields the result.
|
|
282
|
+
# - If within: simply yields the result.
|
|
283
|
+
# Returns the PricingPlans::Result in all cases where execution continues.
|
|
284
|
+
#
|
|
285
|
+
# Usage:
|
|
286
|
+
# with_plan_limit!(:licenses, plan_owner: current_organization, by: 1) do |result|
|
|
287
|
+
# # proceed with side-effects, can inspect result.warning?/grace?
|
|
288
|
+
# end
|
|
289
|
+
def with_plan_limit!(limit_key, plan_owner: nil, by: 1, allow_system_override: false, redirect_to: nil, &block)
|
|
290
|
+
plan_owner ||= (respond_to?(:pricing_plans_plan_owner) ? pricing_plans_plan_owner : nil)
|
|
291
|
+
result = require_plan_limit!(limit_key, plan_owner: plan_owner, by: by, allow_system_override: allow_system_override)
|
|
292
|
+
|
|
293
|
+
if result.blocked?
|
|
294
|
+
# If caller opted into system override, let them proceed (exposes blocked state to the block)
|
|
295
|
+
if allow_system_override && result.metadata && result.metadata[:system_override]
|
|
296
|
+
yield(result) if block_given?
|
|
297
|
+
return result
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Resolve redirect target and delegate to centralized handler if available
|
|
301
|
+
resolved_target = resolve_redirect_target_for_blocked_limit(result, redirect_to)
|
|
302
|
+
if respond_to?(:handle_pricing_plans_limit_blocked)
|
|
303
|
+
enriched_result = PricingPlans::Result.blocked(
|
|
304
|
+
result.message,
|
|
305
|
+
limit_key: result.limit_key,
|
|
306
|
+
plan_owner: result.plan_owner,
|
|
307
|
+
metadata: (result.metadata || {}).merge(redirect_to: resolved_target)
|
|
308
|
+
)
|
|
309
|
+
handle_pricing_plans_limit_blocked(enriched_result)
|
|
310
|
+
else
|
|
311
|
+
if resolved_target && respond_to?(:redirect_to)
|
|
312
|
+
redirect_to(resolved_target, alert: result.message, status: :see_other)
|
|
313
|
+
elsif respond_to?(:render)
|
|
314
|
+
respond_to?(:request) && request&.format&.json? ? render(json: { error: result.message }, status: :forbidden) : render(plain: result.message, status: :forbidden)
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
throw :abort
|
|
318
|
+
else
|
|
319
|
+
if (result.warning? || result.grace?) && respond_to?(:flash) && flash.respond_to?(:[]=)
|
|
320
|
+
flash[:warning] ||= result.message
|
|
321
|
+
end
|
|
322
|
+
yield(result) if block_given?
|
|
323
|
+
return result
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
private
|
|
328
|
+
|
|
329
|
+
# Decide which redirect target to use when a limit is blocked.
|
|
330
|
+
# Resolution order:
|
|
331
|
+
# 1) explicit option passed to the call
|
|
332
|
+
# 2) per-controller default (Symbol | String | Proc)
|
|
333
|
+
# 3) global configuration default (Symbol | String | Proc)
|
|
334
|
+
# 4) pricing_path helper if available
|
|
335
|
+
# Returns a String path or nil
|
|
336
|
+
def resolve_redirect_target_for_blocked_limit(result, explicit)
|
|
337
|
+
return explicit if explicit && !explicit.is_a?(Proc)
|
|
338
|
+
|
|
339
|
+
path = nil
|
|
340
|
+
# Per-controller default
|
|
341
|
+
ctrl = if self.class.respond_to?(:pricing_plans_redirect_on_blocked_limit)
|
|
342
|
+
self.class.pricing_plans_redirect_on_blocked_limit
|
|
343
|
+
elsif self.class.respond_to?(:_pricing_plans_redirect_on_blocked_limit)
|
|
344
|
+
self.class._pricing_plans_redirect_on_blocked_limit
|
|
345
|
+
else
|
|
346
|
+
nil
|
|
347
|
+
end
|
|
348
|
+
candidate = explicit || ctrl
|
|
349
|
+
case candidate
|
|
350
|
+
when Symbol
|
|
351
|
+
path = send(candidate) if respond_to?(candidate)
|
|
352
|
+
when String
|
|
353
|
+
path = candidate
|
|
354
|
+
when Proc
|
|
355
|
+
begin
|
|
356
|
+
path = instance_exec(result, &candidate)
|
|
357
|
+
rescue StandardError
|
|
358
|
+
path = nil
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
if path.nil?
|
|
363
|
+
global = PricingPlans.configuration.redirect_on_blocked_limit rescue nil
|
|
364
|
+
case global
|
|
365
|
+
when Symbol
|
|
366
|
+
path = send(global) if respond_to?(global)
|
|
367
|
+
when String
|
|
368
|
+
path = global
|
|
369
|
+
when Proc
|
|
370
|
+
begin
|
|
371
|
+
path = instance_exec(result, &global)
|
|
372
|
+
rescue StandardError
|
|
373
|
+
path = nil
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
path ||= (respond_to?(:pricing_path) ? pricing_path : nil)
|
|
379
|
+
path
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
public
|
|
383
|
+
|
|
384
|
+
# Preferred alias for feature gating (plain-English name)
|
|
385
|
+
def gate_feature!(feature_key, plan_owner: nil)
|
|
386
|
+
plan_owner ||= (respond_to?(:pricing_plans_plan_owner) ? pricing_plans_plan_owner : nil)
|
|
387
|
+
require_feature!(feature_key, plan_owner: plan_owner)
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def require_feature!(feature_key, plan_owner:)
|
|
391
|
+
plan = PlanResolver.effective_plan_for(plan_owner)
|
|
392
|
+
|
|
393
|
+
unless plan&.allows_feature?(feature_key)
|
|
394
|
+
highlighted_plan = Registry.highlighted_plan
|
|
395
|
+
current_plan_name = plan&.name || Registry.default_plan&.name || "Current"
|
|
396
|
+
feature_human = feature_key.to_s.humanize
|
|
397
|
+
upgrade_message = if PricingPlans.configuration&.message_builder
|
|
398
|
+
begin
|
|
399
|
+
builder = PricingPlans.configuration.message_builder
|
|
400
|
+
builder.call(context: :feature_denied, feature_key: feature_key, plan_owner: plan_owner, plan_name: current_plan_name, highlighted_plan: highlighted_plan&.name)
|
|
401
|
+
rescue StandardError
|
|
402
|
+
nil
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
upgrade_message ||= if highlighted_plan
|
|
406
|
+
"Your current plan (#{current_plan_name}) doesn't allow you to #{feature_human}. Please upgrade to #{highlighted_plan.name} or higher to access #{feature_human}."
|
|
407
|
+
else
|
|
408
|
+
"#{feature_human} is not available on your current plan (#{current_plan_name})."
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
raise FeatureDenied.new(upgrade_message, feature_key: feature_key, plan_owner: plan_owner)
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
true
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
private
|
|
418
|
+
|
|
419
|
+
def handle_within_limit(plan_owner, limit_key, current_usage, limit_amount, by)
|
|
420
|
+
# Check for warning thresholds
|
|
421
|
+
percent_after_action = ((current_usage + by).to_f / limit_amount) * 100
|
|
422
|
+
warning_thresholds = LimitChecker.warning_thresholds(plan_owner, limit_key)
|
|
423
|
+
|
|
424
|
+
crossed_threshold = warning_thresholds.find do |threshold|
|
|
425
|
+
threshold_percent = threshold * 100
|
|
426
|
+
current_percent = (current_usage.to_f / limit_amount) * 100
|
|
427
|
+
|
|
428
|
+
# Threshold will be crossed by this action
|
|
429
|
+
current_percent < threshold_percent && percent_after_action >= threshold_percent
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
if crossed_threshold
|
|
433
|
+
# Emit warning event
|
|
434
|
+
GraceManager.maybe_emit_warning!(plan_owner, limit_key, crossed_threshold)
|
|
435
|
+
|
|
436
|
+
remaining = limit_amount - current_usage - by
|
|
437
|
+
warning_message = build_warning_message(limit_key, remaining, limit_amount)
|
|
438
|
+
metadata = build_metadata(plan_owner, limit_key, current_usage + by, limit_amount)
|
|
439
|
+
Result.warning(warning_message, limit_key: limit_key, plan_owner: plan_owner, metadata: metadata)
|
|
440
|
+
else
|
|
441
|
+
remaining = limit_amount - current_usage - by
|
|
442
|
+
metadata = build_metadata(plan_owner, limit_key, current_usage + by, limit_amount)
|
|
443
|
+
Result.within("#{remaining} #{limit_key.to_s.humanize.downcase} remaining", metadata: metadata)
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def handle_warning_only(plan_owner, limit_key, current_usage, limit_amount)
|
|
448
|
+
warning_message = build_over_limit_message(limit_key, current_usage, limit_amount, :warning)
|
|
449
|
+
metadata = build_metadata(plan_owner, limit_key, current_usage, limit_amount)
|
|
450
|
+
Result.warning(warning_message, limit_key: limit_key, plan_owner: plan_owner, metadata: metadata)
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def handle_immediate_block(plan_owner, limit_key, current_usage, limit_amount)
|
|
454
|
+
blocked_message = build_over_limit_message(limit_key, current_usage, limit_amount, :blocked)
|
|
455
|
+
|
|
456
|
+
# Mark as blocked immediately
|
|
457
|
+
GraceManager.mark_blocked!(plan_owner, limit_key)
|
|
458
|
+
|
|
459
|
+
metadata = build_metadata(plan_owner, limit_key, current_usage, limit_amount)
|
|
460
|
+
Result.blocked(blocked_message, limit_key: limit_key, plan_owner: plan_owner, metadata: metadata)
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def handle_grace_then_block(plan_owner, limit_key, current_usage, limit_amount, limit_config)
|
|
464
|
+
# Check if already in grace or blocked
|
|
465
|
+
if GraceManager.should_block?(plan_owner, limit_key)
|
|
466
|
+
# Mark as blocked if not already blocked
|
|
467
|
+
GraceManager.mark_blocked!(plan_owner, limit_key)
|
|
468
|
+
blocked_message = build_over_limit_message(limit_key, current_usage, limit_amount, :blocked)
|
|
469
|
+
metadata = build_metadata(plan_owner, limit_key, current_usage, limit_amount)
|
|
470
|
+
Result.blocked(blocked_message, limit_key: limit_key, plan_owner: plan_owner, metadata: metadata)
|
|
471
|
+
elsif GraceManager.grace_active?(plan_owner, limit_key)
|
|
472
|
+
# Already in grace period
|
|
473
|
+
grace_ends_at = GraceManager.grace_ends_at(plan_owner, limit_key)
|
|
474
|
+
grace_message = build_grace_message(limit_key, current_usage, limit_amount, grace_ends_at)
|
|
475
|
+
metadata = build_metadata(plan_owner, limit_key, current_usage, limit_amount, grace_ends_at: grace_ends_at)
|
|
476
|
+
Result.grace(grace_message, limit_key: limit_key, plan_owner: plan_owner, metadata: metadata)
|
|
477
|
+
else
|
|
478
|
+
# Start grace period
|
|
479
|
+
GraceManager.mark_exceeded!(plan_owner, limit_key, grace_period: limit_config[:grace])
|
|
480
|
+
grace_ends_at = GraceManager.grace_ends_at(plan_owner, limit_key)
|
|
481
|
+
grace_message = build_grace_message(limit_key, current_usage, limit_amount, grace_ends_at)
|
|
482
|
+
metadata = build_metadata(plan_owner, limit_key, current_usage, limit_amount, grace_ends_at: grace_ends_at)
|
|
483
|
+
Result.grace(grace_message, limit_key: limit_key, plan_owner: plan_owner, metadata: metadata)
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def build_warning_message(limit_key, remaining, limit_amount)
|
|
488
|
+
resource_name = limit_key.to_s.humanize.downcase
|
|
489
|
+
"You have #{remaining} #{resource_name} remaining out of #{limit_amount}"
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
def build_over_limit_message(limit_key, current_usage, limit_amount, severity)
|
|
493
|
+
resource_name = limit_key.to_s.humanize.downcase
|
|
494
|
+
highlighted_plan = Registry.highlighted_plan
|
|
495
|
+
|
|
496
|
+
# Allow global message builder override
|
|
497
|
+
if PricingPlans.configuration&.message_builder
|
|
498
|
+
begin
|
|
499
|
+
built = PricingPlans.configuration.message_builder.call(
|
|
500
|
+
context: :over_limit,
|
|
501
|
+
limit_key: limit_key,
|
|
502
|
+
current_usage: current_usage,
|
|
503
|
+
limit_amount: limit_amount,
|
|
504
|
+
severity: severity,
|
|
505
|
+
highlighted_plan: highlighted_plan&.name
|
|
506
|
+
)
|
|
507
|
+
return built if built
|
|
508
|
+
rescue StandardError
|
|
509
|
+
# fall through to default
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
base_message = "You've reached your limit of #{limit_amount} #{resource_name} (currently using #{current_usage})"
|
|
514
|
+
|
|
515
|
+
return base_message unless highlighted_plan
|
|
516
|
+
upgrade_cta = "Upgrade to #{highlighted_plan.name} for higher limits"
|
|
517
|
+
"#{base_message}. #{upgrade_cta}"
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
def build_grace_message(limit_key, current_usage, limit_amount, grace_ends_at)
|
|
521
|
+
resource_name = limit_key.to_s.humanize.downcase
|
|
522
|
+
highlighted_plan = Registry.highlighted_plan
|
|
523
|
+
|
|
524
|
+
if PricingPlans.configuration&.message_builder
|
|
525
|
+
begin
|
|
526
|
+
built = PricingPlans.configuration.message_builder.call(
|
|
527
|
+
context: :grace,
|
|
528
|
+
limit_key: limit_key,
|
|
529
|
+
current_usage: current_usage,
|
|
530
|
+
limit_amount: limit_amount,
|
|
531
|
+
grace_ends_at: grace_ends_at,
|
|
532
|
+
highlighted_plan: highlighted_plan&.name
|
|
533
|
+
)
|
|
534
|
+
return built if built
|
|
535
|
+
rescue StandardError
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
time_remaining = time_ago_in_words(grace_ends_at)
|
|
540
|
+
base_message = "You've exceeded your limit of #{limit_amount} #{resource_name}. " \
|
|
541
|
+
"You have #{time_remaining} remaining in your grace period"
|
|
542
|
+
|
|
543
|
+
return base_message unless highlighted_plan
|
|
544
|
+
upgrade_cta = "Upgrade to #{highlighted_plan.name} to avoid service interruption"
|
|
545
|
+
"#{base_message}. #{upgrade_cta}"
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def time_ago_in_words(future_time)
|
|
549
|
+
return "no time" if future_time <= Time.current
|
|
550
|
+
|
|
551
|
+
distance = future_time - Time.current
|
|
552
|
+
|
|
553
|
+
case distance
|
|
554
|
+
when 0...60
|
|
555
|
+
"#{distance.round} seconds"
|
|
556
|
+
when 60...3600
|
|
557
|
+
"#{(distance / 60).round} minutes"
|
|
558
|
+
when 3600...86400
|
|
559
|
+
"#{(distance / 3600).round} hours"
|
|
560
|
+
else
|
|
561
|
+
"#{(distance / 86400).round} days"
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def build_metadata(plan_owner, limit_key, usage, limit_amount, grace_ends_at: nil)
|
|
566
|
+
{
|
|
567
|
+
limit_amount: limit_amount,
|
|
568
|
+
current_usage: usage,
|
|
569
|
+
percent_used: (limit_amount == :unlimited || limit_amount.to_i.zero?) ? 0.0 : [(usage.to_f / limit_amount) * 100, 100.0].min,
|
|
570
|
+
grace_ends_at: grace_ends_at
|
|
571
|
+
}
|
|
572
|
+
end
|
|
573
|
+
end
|
|
574
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PricingPlans
|
|
4
|
+
# Default controller-level rescues for a great out-of-the-box DX.
|
|
5
|
+
# Included automatically by the engine into ActionController::Base and ActionController::API.
|
|
6
|
+
# Applications can override by defining their own rescue_from handlers.
|
|
7
|
+
module ControllerRescues
|
|
8
|
+
def self.included(base)
|
|
9
|
+
# Install a default mapping for FeatureDenied → 403 with helpful messaging.
|
|
10
|
+
if base.respond_to?(:rescue_from)
|
|
11
|
+
base.rescue_from(PricingPlans::FeatureDenied) do |error|
|
|
12
|
+
handle_pricing_plans_feature_denied(error)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
# Default behavior tries to respond appropriately for HTML and JSON.
|
|
20
|
+
# - HTML/Turbo: set a flash alert with an idiomatic message and redirect to pricing if available; otherwise render 403 with flash.now
|
|
21
|
+
# - JSON: 403 with structured error { error, feature, plan }
|
|
22
|
+
# Apps can override by defining this method in their own ApplicationController.
|
|
23
|
+
def handle_pricing_plans_feature_denied(error)
|
|
24
|
+
if html_request?
|
|
25
|
+
# Prefer redirect + flash for idiomatic Rails UX when we have a pricing_path
|
|
26
|
+
if respond_to?(:pricing_path)
|
|
27
|
+
flash[:alert] = error.message
|
|
28
|
+
redirect_to(pricing_path, status: :see_other)
|
|
29
|
+
else
|
|
30
|
+
# No pricing route helper; render with 403 and show inline flash
|
|
31
|
+
flash.now[:alert] = error.message if respond_to?(:flash) && flash.respond_to?(:now)
|
|
32
|
+
respond_to?(:render) ? render(status: :forbidden, plain: error.message) : head(:forbidden)
|
|
33
|
+
end
|
|
34
|
+
elsif json_request?
|
|
35
|
+
payload = {
|
|
36
|
+
error: error.message,
|
|
37
|
+
feature: (error.respond_to?(:feature_key) ? error.feature_key : nil),
|
|
38
|
+
plan: begin
|
|
39
|
+
if error.respond_to?(:plan_owner)
|
|
40
|
+
plan_obj = PricingPlans::PlanResolver.effective_plan_for(error.plan_owner)
|
|
41
|
+
elsif error.respond_to?(:plan_owner)
|
|
42
|
+
plan_obj = PricingPlans::PlanResolver.effective_plan_for(error.plan_owner)
|
|
43
|
+
else
|
|
44
|
+
plan_obj = nil
|
|
45
|
+
end
|
|
46
|
+
plan_obj&.name
|
|
47
|
+
rescue StandardError
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
}.compact
|
|
51
|
+
render(json: payload, status: :forbidden)
|
|
52
|
+
else
|
|
53
|
+
# API or miscellaneous formats
|
|
54
|
+
if respond_to?(:render)
|
|
55
|
+
render(json: { error: error.message }, status: :forbidden)
|
|
56
|
+
else
|
|
57
|
+
head :forbidden if respond_to?(:head)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Centralized handler for plan limit blocks. Apps can override this method
|
|
63
|
+
# in their own ApplicationController to customize redirects/flash.
|
|
64
|
+
# Receives the PricingPlans::Result for the blocked check.
|
|
65
|
+
def handle_pricing_plans_limit_blocked(result)
|
|
66
|
+
message = result&.message || "Plan limit reached"
|
|
67
|
+
redirect_target = (result&.metadata || {})[:redirect_to]
|
|
68
|
+
|
|
69
|
+
if html_request?
|
|
70
|
+
# Prefer explicit/derived redirect target if provided by the guard
|
|
71
|
+
if redirect_target
|
|
72
|
+
flash[:alert] = message if respond_to?(:flash)
|
|
73
|
+
redirect_to(redirect_target, status: :see_other) if respond_to?(:redirect_to)
|
|
74
|
+
elsif respond_to?(:pricing_path)
|
|
75
|
+
flash[:alert] = message if respond_to?(:flash)
|
|
76
|
+
redirect_to(pricing_path, status: :see_other) if respond_to?(:redirect_to)
|
|
77
|
+
else
|
|
78
|
+
flash.now[:alert] = message if respond_to?(:flash) && flash.respond_to?(:now)
|
|
79
|
+
render(status: :forbidden, plain: message) if respond_to?(:render)
|
|
80
|
+
end
|
|
81
|
+
elsif json_request?
|
|
82
|
+
payload = {
|
|
83
|
+
error: message,
|
|
84
|
+
limit: result&.limit_key,
|
|
85
|
+
plan: begin
|
|
86
|
+
plan_obj = PricingPlans::PlanResolver.effective_plan_for(result&.plan_owner)
|
|
87
|
+
plan_obj&.name
|
|
88
|
+
rescue StandardError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
}.compact
|
|
92
|
+
render(json: payload, status: :forbidden) if respond_to?(:render)
|
|
93
|
+
else
|
|
94
|
+
render(json: { error: message }, status: :forbidden) if respond_to?(:render)
|
|
95
|
+
head :forbidden if respond_to?(:head)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def html_request?
|
|
100
|
+
return false unless respond_to?(:request)
|
|
101
|
+
req = request
|
|
102
|
+
req && req.respond_to?(:format) && req.format.respond_to?(:html?) && req.format.html?
|
|
103
|
+
rescue StandardError
|
|
104
|
+
false
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def json_request?
|
|
108
|
+
return false unless respond_to?(:request)
|
|
109
|
+
req = request
|
|
110
|
+
req && req.respond_to?(:format) && req.format.respond_to?(:json?) && req.format.json?
|
|
111
|
+
rescue StandardError
|
|
112
|
+
false
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|