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,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