pricing_plans 0.2.0 → 0.3.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.
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PricingPlans
4
+ # Centralized callback dispatch module with error isolation.
5
+ # Callbacks should never break the main operation - errors are logged but not raised.
6
+ module Callbacks
7
+ module_function
8
+
9
+ # Dispatch a callback event with error isolation.
10
+ # Fires specific handler first (if exists), then wildcard handler (if exists).
11
+ # Callbacks should never break the main operation.
12
+ #
13
+ # @param event_type [Symbol] The event type (:warning, :grace_start, :block)
14
+ # @param limit_key [Symbol] The limit key (e.g., :projects, :licenses)
15
+ # @param args [Array] Arguments to pass to the callback (plan_owner, plus event-specific args)
16
+ def dispatch(event_type, limit_key, *args)
17
+ handlers = Registry.event_handlers[event_type] || {}
18
+
19
+ # Build full args with limit_key injected after plan_owner
20
+ # Input args: [plan_owner, ...event_specific_args]
21
+ # Output: [plan_owner, limit_key, ...event_specific_args]
22
+ plan_owner = args.first
23
+ event_args = args.drop(1)
24
+ full_args = [plan_owner, limit_key, *event_args]
25
+
26
+ # Fire specific handler first
27
+ specific_handler = handlers[limit_key]
28
+ execute_safely(specific_handler, event_type, limit_key, full_args) if specific_handler.is_a?(Proc)
29
+
30
+ # Fire wildcard handler second
31
+ wildcard_handler = handlers[:_all]
32
+ execute_safely(wildcard_handler, event_type, limit_key, full_args) if wildcard_handler.is_a?(Proc)
33
+ end
34
+
35
+ # Execute callback with error isolation and arity handling.
36
+ # Supports callbacks with varying argument counts for backwards compatibility.
37
+ #
38
+ # Backward compatibility:
39
+ # - Arity 2 (old style): receives (plan_owner, last_arg) - skips limit_key
40
+ # - Arity 3+ (new style): receives (plan_owner, limit_key, ...rest)
41
+ #
42
+ # @param handler [Proc] The callback to execute
43
+ # @param event_type [Symbol] For logging purposes
44
+ # @param limit_key [Symbol] For logging purposes
45
+ # @param args [Array] Full arguments array [plan_owner, limit_key, ...event_specific_args]
46
+ def execute_safely(handler, event_type, limit_key, args)
47
+ case handler.arity
48
+ when 0
49
+ handler.call
50
+ when 1
51
+ handler.call(args[0])
52
+ when 2
53
+ # Backward compatibility: old callbacks expect (plan_owner, event_arg)
54
+ # where event_arg is threshold for warnings, grace_ends_at for grace_start.
55
+ # Skip limit_key (args[1]) and pass plan_owner + last arg.
56
+ # For on_block (args = [plan_owner, limit_key]), this passes (plan_owner, limit_key).
57
+ handler.call(args[0], args.last)
58
+ when 3
59
+ handler.call(args[0], args[1], args[2])
60
+ when -1, -2, -3 # Variable arity (splat args)
61
+ handler.call(*args)
62
+ else
63
+ handler.call(*args.first(handler.arity.abs))
64
+ end
65
+ rescue StandardError => e
66
+ # Log but don't re-raise - callbacks should never break model creation
67
+ log_error("[PricingPlans] Callback error for #{event_type}:#{limit_key}: #{e.class}: #{e.message}")
68
+ log_debug(e.backtrace&.join("\n"))
69
+ end
70
+
71
+ # Check warning thresholds and emit warning event if a new threshold is crossed.
72
+ # This is the main entry point for automatic warning detection.
73
+ #
74
+ # @param plan_owner [Object] The plan owner (e.g., Organization)
75
+ # @param limit_key [Symbol] The limit key
76
+ # @param current_usage [Integer] Current usage count (after the action)
77
+ # @param limit_amount [Integer] The configured limit
78
+ def check_and_emit_warnings!(plan_owner, limit_key, current_usage, limit_amount)
79
+ return if limit_amount == :unlimited || limit_amount.to_i.zero?
80
+
81
+ percent_used = (current_usage.to_f / limit_amount) * 100
82
+ thresholds = LimitChecker.warning_thresholds(plan_owner, limit_key)
83
+
84
+ # Find the highest threshold that has been crossed
85
+ crossed_threshold = thresholds.select { |t| percent_used >= (t * 100) }.max
86
+ return unless crossed_threshold
87
+
88
+ # Emit warning if this is a new higher threshold
89
+ GraceManager.maybe_emit_warning!(plan_owner, limit_key, crossed_threshold)
90
+ end
91
+
92
+ # Check if limit is exceeded and handle grace state (for grace_then_block policy).
93
+ # This is called after a successful model creation, so it only handles:
94
+ # - :just_warn - emit additional warning
95
+ # - :grace_then_block - start grace period if exceeded and not already in grace
96
+ #
97
+ # NOTE: Block events are NOT emitted here because this runs after successful creation.
98
+ # Block events are emitted in Limitable validation when creation is actually blocked.
99
+ #
100
+ # @param plan_owner [Object] The plan owner
101
+ # @param limit_key [Symbol] The limit key
102
+ # @param current_usage [Integer] Current usage count
103
+ # @param limit_config [Hash] The limit configuration from the plan
104
+ def check_and_emit_limit_exceeded!(plan_owner, limit_key, current_usage, limit_config)
105
+ return unless limit_config
106
+ return if limit_config[:to] == :unlimited
107
+
108
+ limit_amount = limit_config[:to].to_i
109
+ return unless current_usage >= limit_amount
110
+
111
+ case limit_config[:after_limit]
112
+ when :just_warn
113
+ # Just emit warning, don't track grace/block
114
+ check_and_emit_warnings!(plan_owner, limit_key, current_usage, limit_amount)
115
+ when :block_usage
116
+ # Do NOT mark as blocked here - this callback runs after SUCCESSFUL creation.
117
+ # Block events are emitted from validation when creation is actually blocked.
118
+ nil
119
+ when :grace_then_block
120
+ # Start grace period if not already in grace/blocked
121
+ unless GraceManager.grace_active?(plan_owner, limit_key) || GraceManager.should_block?(plan_owner, limit_key)
122
+ GraceManager.mark_exceeded!(plan_owner, limit_key, grace_period: limit_config[:grace])
123
+ end
124
+ end
125
+ end
126
+
127
+ # Safe logging that works with or without Rails
128
+ def log_error(message)
129
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
130
+ Rails.logger.error(message)
131
+ elsif PricingPlans.configuration&.debug
132
+ warn message
133
+ end
134
+ end
135
+
136
+ def log_warn(message)
137
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
138
+ Rails.logger.warn(message)
139
+ elsif PricingPlans.configuration&.debug
140
+ warn message
141
+ end
142
+ end
143
+
144
+ def log_debug(message)
145
+ return unless message
146
+
147
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger&.debug?
148
+ Rails.logger.debug(message)
149
+ end
150
+ end
151
+ end
152
+ end
@@ -12,11 +12,11 @@ module PricingPlans
12
12
  # Debug mode - set to true to enable debug output
13
13
  attr_accessor :debug
14
14
  # Global controller ergonomics
15
- # Optional global resolver for controller plan owner. Per-controller settings still win.
16
- # Accepts:
17
- # - Symbol: a controller helper to call (e.g., :current_organization)
18
- # - Proc: instance-exec'd in the controller (self is the controller)
19
- attr_reader :controller_plan_owner_method, :controller_plan_owner_proc
15
+ # Optional global resolver for controller plan owner. Per-controller settings still win.
16
+ # Accepts:
17
+ # - Symbol: a controller helper to call (e.g., :current_organization)
18
+ # - Proc: instance-exec'd in the controller (self is the controller)
19
+ attr_reader :controller_plan_owner_method, :controller_plan_owner_proc
20
20
  # When a limit check blocks, controllers can redirect to a global default target.
21
21
  # Accepts:
22
22
  # - Symbol: a controller helper to call (e.g., :pricing_path)
@@ -119,19 +119,31 @@ module PricingPlans
119
119
  end
120
120
  end
121
121
 
122
- def on_warning(limit_key, &block)
122
+ # Register a callback for warning events.
123
+ # @param limit_key [Symbol, nil] The specific limit key, or omit for wildcard (all limits)
124
+ # @yield [plan_owner, limit_key, threshold] Block to execute when warning fires
125
+ def on_warning(limit_key = nil, &block)
123
126
  raise PricingPlans::ConfigurationError, "Block required for on_warning" unless block_given?
124
- @event_handlers[:warning][limit_key] = block
127
+ key = limit_key || :_all
128
+ @event_handlers[:warning][key] = block
125
129
  end
126
130
 
127
- def on_grace_start(limit_key, &block)
131
+ # Register a callback for grace period start events.
132
+ # @param limit_key [Symbol, nil] The specific limit key, or omit for wildcard (all limits)
133
+ # @yield [plan_owner, limit_key, grace_ends_at] Block to execute when grace starts
134
+ def on_grace_start(limit_key = nil, &block)
128
135
  raise PricingPlans::ConfigurationError, "Block required for on_grace_start" unless block_given?
129
- @event_handlers[:grace_start][limit_key] = block
136
+ key = limit_key || :_all
137
+ @event_handlers[:grace_start][key] = block
130
138
  end
131
139
 
132
- def on_block(limit_key, &block)
140
+ # Register a callback for block events.
141
+ # @param limit_key [Symbol, nil] The specific limit key, or omit for wildcard (all limits)
142
+ # @yield [plan_owner, limit_key] Block to execute when user is blocked
143
+ def on_block(limit_key = nil, &block)
133
144
  raise PricingPlans::ConfigurationError, "Block required for on_block" unless block_given?
134
- @event_handlers[:block][limit_key] = block
145
+ key = limit_key || :_all
146
+ @event_handlers[:block][key] = block
135
147
  end
136
148
 
137
149
  def validate!
@@ -8,8 +8,9 @@ module PricingPlans
8
8
  # Track all limited_by configurations for this model
9
9
  class_attribute :pricing_plans_limits, default: {}
10
10
 
11
- # Callbacks for automatic tracking
11
+ # Callbacks for automatic tracking and event emission
12
12
  after_create :increment_per_period_counters
13
+ after_commit :check_and_emit_limit_events, on: :create
13
14
  after_destroy :decrement_persistent_counters
14
15
  # Add plan_owner-centric convenience methods to instances of the plan_owner class
15
16
  # when possible. These are no-ops if the model isn't the plan_owner itself.
@@ -213,6 +214,8 @@ module PricingPlans
213
214
  return
214
215
  when :block_usage, :grace_then_block
215
216
  if limit_config[:after_limit] == :block_usage || GraceManager.should_block?(plan_owner_instance, limit_key)
217
+ # Emit block event before failing validation
218
+ GraceManager.mark_blocked!(plan_owner_instance, limit_key)
216
219
  message = error_after_limit || "Cannot create #{self.class.name.downcase}: #{limit_key} limit exceeded"
217
220
  errors.add(:base, message)
218
221
  end
@@ -227,6 +230,8 @@ module PricingPlans
227
230
  return
228
231
  when :block_usage, :grace_then_block
229
232
  if limit_config[:after_limit] == :block_usage || GraceManager.should_block?(plan_owner_instance, limit_key)
233
+ # Emit block event before failing validation
234
+ GraceManager.mark_blocked!(plan_owner_instance, limit_key)
230
235
  message = error_after_limit || "Cannot create #{self.class.name.downcase}: #{limit_key} limit exceeded for this period"
231
236
  errors.add(:base, message)
232
237
  end
@@ -289,5 +294,37 @@ module PricingPlans
289
294
  # since the counter is computed live from the database
290
295
  # The record being destroyed will automatically reduce the count
291
296
  end
297
+
298
+ # Automatically check warning thresholds and emit events after model creation.
299
+ # This ensures callbacks fire without requiring explicit controller guard calls.
300
+ def check_and_emit_limit_events
301
+ self.class.pricing_plans_limits.each do |limit_key, config|
302
+ plan_owner_instance = if config[:plan_owner_method] == :self
303
+ self
304
+ else
305
+ send(config[:plan_owner_method])
306
+ end
307
+
308
+ unless plan_owner_instance
309
+ Callbacks.log_debug("[PricingPlans] Skipping callback for #{self.class.name}##{id}: plan_owner is nil (#{config[:plan_owner_method]})")
310
+ next
311
+ end
312
+
313
+ plan = PlanResolver.effective_plan_for(plan_owner_instance)
314
+ limit_config = plan&.limit_for(limit_key)
315
+
316
+ next unless limit_config
317
+ next if limit_config[:to] == :unlimited
318
+
319
+ limit_amount = limit_config[:to].to_i
320
+ current_usage = LimitChecker.current_usage_for(plan_owner_instance, limit_key, limit_config)
321
+
322
+ # Check and emit warning events for thresholds crossed
323
+ Callbacks.check_and_emit_warnings!(plan_owner_instance, limit_key, current_usage, limit_amount)
324
+
325
+ # Check and emit grace/block events if limit is exceeded
326
+ Callbacks.check_and_emit_limit_exceeded!(plan_owner_instance, limit_key, current_usage, limit_config)
327
+ end
328
+ end
292
329
  end
293
330
  end
@@ -7,7 +7,7 @@ module PricingPlans
7
7
  belongs_to :plan_owner, polymorphic: true
8
8
 
9
9
  validates :limit_key, presence: true
10
- validates :plan_owner_type, :plan_owner_id, :limit_key, uniqueness: { scope: [:plan_owner_type, :plan_owner_id] }
10
+ validates :limit_key, uniqueness: { scope: [:plan_owner_type, :plan_owner_id] }
11
11
 
12
12
  scope :exceeded, -> { where.not(exceeded_at: nil) }
13
13
  scope :blocked, -> { where.not(blocked_at: nil) }
@@ -11,6 +11,12 @@ module PricingPlans
11
11
  calculate_window_for_period(plan_owner, period_type)
12
12
  end
13
13
 
14
+ # Calculate window with pre-resolved period_type to avoid redundant plan lookups.
15
+ # Used by StatusContext which has already resolved the limit config.
16
+ def window_for_period_type(plan_owner, period_type)
17
+ calculate_window_for_period(plan_owner, period_type)
18
+ end
19
+
14
20
  private
15
21
 
16
22
  # Backward-compatible shim for tests that stub pay_available?
@@ -139,6 +139,9 @@ module PricingPlans
139
139
  end
140
140
  end
141
141
 
142
+ alias_method :set_metadata, :set_meta
143
+ alias_method :metadata, :meta
144
+
142
145
  # CTA helpers for pricing UI
143
146
  def set_cta_text(value)
144
147
  @cta_text = value&.to_s
@@ -169,7 +172,7 @@ module PricingPlans
169
172
  default = PricingPlans.configuration.default_cta_url
170
173
  return default if default
171
174
  # New default: if host app defines subscribe_path, prefer that
172
- if defined?(Rails) && Rails.application.routes.url_helpers.respond_to?(:subscribe_path)
175
+ if defined?(Rails) && Rails.respond_to?(:application) && Rails.application && Rails.application.routes.url_helpers.respond_to?(:subscribe_path)
173
176
  return Rails.application.routes.url_helpers.subscribe_path(plan: key, interval: :month)
174
177
  end
175
178
  nil
@@ -480,6 +483,7 @@ module PricingPlans
480
483
  name: name,
481
484
  description: description,
482
485
  features: bullets, # alias in this gem
486
+ metadata: metadata.dup,
483
487
  highlighted: highlighted?,
484
488
  default: default?,
485
489
  free: free?,
@@ -110,6 +110,76 @@ module PricingPlans
110
110
  end
111
111
 
112
112
  module ClassMethods
113
+ # === Class-level scopes for admin dashboards ===
114
+ # These scopes allow querying plan owners by their limits status.
115
+ # Useful for admin dashboards to find "organizations needing attention".
116
+
117
+ # Plan owners with any limit that has been exceeded (exceeded_at is set)
118
+ # Includes both those in grace period and those that are blocked.
119
+ def with_exceeded_limits
120
+ joins_enforcement_states.where.not(pricing_plans_enforcement_states: { exceeded_at: nil }).distinct
121
+ end
122
+
123
+ # Plan owners with any limit that is blocked (blocked_at is set)
124
+ def with_blocked_limits
125
+ joins_enforcement_states.where.not(pricing_plans_enforcement_states: { blocked_at: nil }).distinct
126
+ end
127
+
128
+ # Plan owners with limits in grace period (exceeded but not yet blocked)
129
+ def in_grace_period
130
+ joins_enforcement_states
131
+ .where.not(pricing_plans_enforcement_states: { exceeded_at: nil })
132
+ .where(pricing_plans_enforcement_states: { blocked_at: nil })
133
+ .distinct
134
+ end
135
+
136
+ # Plan owners with no exceeded limits (complement of with_exceeded_limits).
137
+ # Uses LEFT OUTER JOIN for better performance than subquery on large tables.
138
+ def within_all_limits
139
+ left_outer_joins_exceeded_enforcement_states
140
+ .where(pricing_plans_enforcement_states: { id: nil })
141
+ end
142
+
143
+ # Alias for with_exceeded_limits - plan owners that need attention
144
+ # (either exceeded or blocked on any limit)
145
+ def needing_attention
146
+ with_exceeded_limits
147
+ end
148
+
149
+ private
150
+
151
+ # Helper to join with enforcement_states table using polymorphic association.
152
+ # Uses Arel for clean, database-agnostic SQL construction.
153
+ def joins_enforcement_states
154
+ enforcement_states = PricingPlans::EnforcementState.arel_table
155
+ owners = arel_table
156
+
157
+ joins(
158
+ owners.join(enforcement_states)
159
+ .on(
160
+ enforcement_states[:plan_owner_id].eq(owners[:id])
161
+ .and(enforcement_states[:plan_owner_type].eq(name))
162
+ )
163
+ .join_sources
164
+ )
165
+ end
166
+
167
+ # LEFT OUTER JOIN with exceeded_at condition in the join clause.
168
+ # Returns all plan owners, with NULL for those without exceeded limits.
169
+ def left_outer_joins_exceeded_enforcement_states
170
+ enforcement_states = PricingPlans::EnforcementState.arel_table
171
+ owners = arel_table
172
+
173
+ joins(
174
+ owners.join(enforcement_states, Arel::Nodes::OuterJoin)
175
+ .on(
176
+ enforcement_states[:plan_owner_id].eq(owners[:id])
177
+ .and(enforcement_states[:plan_owner_type].eq(name))
178
+ .and(enforcement_states[:exceeded_at].not_eq(nil))
179
+ )
180
+ .join_sources
181
+ )
182
+ end
113
183
  end
114
184
 
115
185
  def within_plan_limits?(limit_key, by: 1)
@@ -10,19 +10,7 @@ module PricingPlans
10
10
  def effective_plan_for(plan_owner)
11
11
  log_debug "[PricingPlans::PlanResolver] effective_plan_for called for #{plan_owner.class.name}##{plan_owner.respond_to?(:id) ? plan_owner.id : 'N/A'}"
12
12
 
13
- # 1. Check Pay subscription status first (no app-specific gate required)
14
- pay_available = PaySupport.pay_available?
15
- log_debug "[PricingPlans::PlanResolver] PaySupport.pay_available? = #{pay_available}"
16
- log_debug "[PricingPlans::PlanResolver] defined?(Pay) = #{defined?(Pay)}"
17
-
18
- if pay_available
19
- log_debug "[PricingPlans::PlanResolver] Calling resolve_plan_from_pay..."
20
- plan_from_pay = resolve_plan_from_pay(plan_owner)
21
- log_debug "[PricingPlans::PlanResolver] resolve_plan_from_pay returned: #{plan_from_pay ? plan_from_pay.key : 'nil'}"
22
- return plan_from_pay if plan_from_pay
23
- end
24
-
25
- # 2. Check manual assignment
13
+ # 1. Check manual assignment FIRST (admin overrides take precedence)
26
14
  log_debug "[PricingPlans::PlanResolver] Checking for manual assignment..."
27
15
  if plan_owner.respond_to?(:id)
28
16
  assignment = Assignment.find_by(
@@ -37,6 +25,18 @@ module PricingPlans
37
25
  end
38
26
  end
39
27
 
28
+ # 2. Check Pay subscription status
29
+ pay_available = PaySupport.pay_available?
30
+ log_debug "[PricingPlans::PlanResolver] PaySupport.pay_available? = #{pay_available}"
31
+ log_debug "[PricingPlans::PlanResolver] defined?(Pay) = #{defined?(Pay)}"
32
+
33
+ if pay_available
34
+ log_debug "[PricingPlans::PlanResolver] Calling resolve_plan_from_pay..."
35
+ plan_from_pay = resolve_plan_from_pay(plan_owner)
36
+ log_debug "[PricingPlans::PlanResolver] resolve_plan_from_pay returned: #{plan_from_pay ? plan_from_pay.key : 'nil'}"
37
+ return plan_from_pay if plan_from_pay
38
+ end
39
+
40
40
  # 3. Fall back to default plan
41
41
  default = Registry.default_plan
42
42
  log_debug "[PricingPlans::PlanResolver] Returning default plan: #{default ? default.key : 'nil'}"
@@ -75,8 +75,8 @@ module PricingPlans
75
75
  end
76
76
 
77
77
  def emit_event(event_type, limit_key, *args)
78
- handler = event_handlers.dig(event_type, limit_key)
79
- handler&.call(*args)
78
+ # Delegate to Callbacks module for error-isolated execution
79
+ Callbacks.dispatch(event_type, limit_key, *args)
80
80
  end
81
81
 
82
82
  private