pricing_plans 0.2.1 → 0.3.1

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.
@@ -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?
@@ -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)
@@ -133,6 +203,16 @@ module PricingPlans
133
203
  plan&.free? || false
134
204
  end
135
205
 
206
+ def has_plan_assignment?
207
+ return false unless respond_to?(:id) && id.present?
208
+ Assignment.exists?(plan_owner_type: self.class.name, plan_owner_id: id)
209
+ end
210
+
211
+ def plan_assignment
212
+ return nil unless respond_to?(:id) && id.present?
213
+ Assignment.find_by(plan_owner_type: self.class.name, plan_owner_id: id)
214
+ end
215
+
136
216
  def assign_pricing_plan!(plan_key, source: "manual")
137
217
  Assignment.assign_plan_to(self, plan_key, source: source)
138
218
  end
@@ -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
@@ -0,0 +1,343 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PricingPlans
4
+ # Request-scoped context that caches computed values within a single status() call.
5
+ # This eliminates the N+1 query problem where helper methods like severity_for(),
6
+ # message_for(), overage_for() etc. all re-call limit_status() internally.
7
+ #
8
+ # Thread-safe by design: each call to status() gets its own context instance.
9
+ class StatusContext
10
+ attr_reader :plan_owner
11
+
12
+ def initialize(plan_owner)
13
+ @plan_owner = plan_owner
14
+ @plan_cache = nil
15
+ @limit_config_cache = {}
16
+ @limit_status_cache = {}
17
+ @usage_cache = {}
18
+ @grace_active_cache = {}
19
+ @grace_ends_at_cache = {}
20
+ @should_block_cache = {}
21
+ @percent_used_cache = {}
22
+ @warning_thresholds_cache = {}
23
+ @severity_cache = {}
24
+ end
25
+
26
+ # ========== PUBLIC API ==========
27
+
28
+ # Cached plan resolution - called once per context
29
+ def effective_plan
30
+ @plan_cache ||= PlanResolver.effective_plan_for(@plan_owner)
31
+ end
32
+
33
+ # Cached limit config lookup
34
+ def limit_config_for(limit_key)
35
+ key = limit_key.to_sym
36
+ return @limit_config_cache[key] if @limit_config_cache.key?(key)
37
+ @limit_config_cache[key] = effective_plan&.limit_for(limit_key)
38
+ end
39
+
40
+ # Cached current usage lookup
41
+ def current_usage_for(limit_key)
42
+ key = limit_key.to_sym
43
+ return @usage_cache[key] if @usage_cache.key?(key)
44
+
45
+ limit_config = limit_config_for(limit_key)
46
+ @usage_cache[key] = limit_config ? LimitChecker.current_usage_for(@plan_owner, limit_key, limit_config) : 0
47
+ end
48
+
49
+ # Cached percent used
50
+ def percent_used_for(limit_key)
51
+ key = limit_key.to_sym
52
+ return @percent_used_cache[key] if @percent_used_cache.key?(key)
53
+
54
+ limit_config = limit_config_for(limit_key)
55
+ return @percent_used_cache[key] = 0.0 unless limit_config
56
+
57
+ limit_amount = limit_config[:to]
58
+ return @percent_used_cache[key] = 0.0 if limit_amount == :unlimited || limit_amount.to_i.zero?
59
+
60
+ usage = current_usage_for(limit_key)
61
+ @percent_used_cache[key] = [(usage.to_f / limit_amount) * 100, 100.0].min
62
+ end
63
+
64
+ # Cached grace active check - implemented directly to avoid GraceManager's plan resolution
65
+ def grace_active?(limit_key)
66
+ key = limit_key.to_sym
67
+ return @grace_active_cache[key] if @grace_active_cache.key?(key)
68
+
69
+ state = fresh_enforcement_state(limit_key)
70
+ return @grace_active_cache[key] = false unless state&.exceeded?
71
+ @grace_active_cache[key] = !state.grace_expired?
72
+ end
73
+
74
+ # Cached grace ends at - uses fresh_enforcement_state to avoid stale data
75
+ def grace_ends_at(limit_key)
76
+ key = limit_key.to_sym
77
+ return @grace_ends_at_cache[key] if @grace_ends_at_cache.key?(key)
78
+
79
+ state = fresh_enforcement_state(limit_key)
80
+ @grace_ends_at_cache[key] = state&.grace_ends_at
81
+ end
82
+
83
+ # Cached should block check - implemented directly to avoid GraceManager's plan resolution
84
+ def should_block?(limit_key)
85
+ key = limit_key.to_sym
86
+ return @should_block_cache[key] if @should_block_cache.key?(key)
87
+
88
+ limit_config = limit_config_for(limit_key)
89
+ return @should_block_cache[key] = false unless limit_config
90
+
91
+ after_limit = limit_config[:after_limit]
92
+ return @should_block_cache[key] = false if after_limit == :just_warn
93
+
94
+ limit_amount = limit_config[:to]
95
+ return @should_block_cache[key] = false if limit_amount == :unlimited
96
+
97
+ current_usage = current_usage_for(limit_key)
98
+ exceeded = current_usage >= limit_amount.to_i
99
+ exceeded = false if limit_amount.to_i.zero? && current_usage.to_i.zero?
100
+
101
+ return @should_block_cache[key] = exceeded if after_limit == :block_usage
102
+
103
+ # For :grace_then_block, check if grace period expired
104
+ return @should_block_cache[key] = false unless exceeded
105
+
106
+ state = fresh_enforcement_state(limit_key)
107
+ return @should_block_cache[key] = false unless state&.exceeded?
108
+ @should_block_cache[key] = state.grace_expired?
109
+ end
110
+
111
+ # Cached warning thresholds
112
+ def warning_thresholds(limit_key)
113
+ key = limit_key.to_sym
114
+ return @warning_thresholds_cache[key] if @warning_thresholds_cache.key?(key)
115
+
116
+ limit_config = limit_config_for(limit_key)
117
+ @warning_thresholds_cache[key] = limit_config ? (limit_config[:warn_at] || []) : []
118
+ end
119
+
120
+ # Cached period window calculation - delegates to PeriodCalculator with pre-resolved period_type
121
+ # to avoid redundant effective_plan_for calls
122
+ def period_window_for(limit_key)
123
+ key = limit_key.to_sym
124
+ @period_window_cache ||= {}
125
+ return @period_window_cache[key] if @period_window_cache.key?(key)
126
+
127
+ limit_config = limit_config_for(limit_key)
128
+ return @period_window_cache[key] = [nil, nil] unless limit_config && limit_config[:per]
129
+
130
+ period_type = limit_config[:per] || PricingPlans::Registry.configuration.period_cycle
131
+ @period_window_cache[key] = PeriodCalculator.window_for_period_type(@plan_owner, period_type)
132
+ end
133
+
134
+ # Full limit status hash - cached, computed from other cached values
135
+ def limit_status(limit_key)
136
+ key = limit_key.to_sym
137
+ return @limit_status_cache[key] if @limit_status_cache.key?(key)
138
+
139
+ @limit_status_cache[key] = compute_limit_status(limit_key)
140
+ end
141
+
142
+ # Severity for a single limit - computed from cached data
143
+ def severity_for(limit_key)
144
+ key = limit_key.to_sym
145
+ return @severity_cache[key] if @severity_cache.key?(key)
146
+
147
+ @severity_cache[key] = compute_severity(limit_key)
148
+ end
149
+
150
+ # Highest severity across multiple limits
151
+ def highest_severity_for(*limit_keys)
152
+ keys = limit_keys.flatten
153
+ per_key = keys.map { |k| severity_for(k) }
154
+
155
+ return :blocked if per_key.include?(:blocked)
156
+ return :grace if per_key.include?(:grace)
157
+ return :at_limit if per_key.include?(:at_limit)
158
+ per_key.include?(:warning) ? :warning : :ok
159
+ end
160
+
161
+ # Message for a single limit
162
+ def message_for(limit_key)
163
+ st = limit_status(limit_key)
164
+ return nil unless st[:configured]
165
+
166
+ severity = severity_for(limit_key)
167
+ return nil if severity == :ok
168
+
169
+ cfg = PricingPlans.configuration
170
+ current_usage = st[:current_usage]
171
+ limit_amount = st[:limit_amount]
172
+ ends_at = st[:grace_ends_at]
173
+
174
+ if cfg.message_builder
175
+ context = case severity
176
+ when :blocked then :over_limit
177
+ when :grace then :grace
178
+ when :at_limit then :at_limit
179
+ else :warning
180
+ end
181
+ begin
182
+ custom = cfg.message_builder.call(
183
+ context: context,
184
+ limit_key: limit_key,
185
+ current_usage: current_usage,
186
+ limit_amount: limit_amount,
187
+ grace_ends_at: ends_at
188
+ )
189
+ return custom if custom
190
+ rescue StandardError
191
+ # fall through to defaults
192
+ end
193
+ end
194
+
195
+ noun = begin
196
+ PricingPlans.noun_for(limit_key)
197
+ rescue StandardError
198
+ "limit"
199
+ end
200
+
201
+ case severity
202
+ when :blocked
203
+ if limit_amount.is_a?(Numeric)
204
+ "You've gone over your #{noun} for #{limit_key.to_s.humanize.downcase} (#{current_usage}/#{limit_amount}). Please upgrade your plan."
205
+ else
206
+ "You've gone over your #{noun} for #{limit_key.to_s.humanize.downcase}. Please upgrade your plan."
207
+ end
208
+ when :grace
209
+ deadline = ends_at ? ", and your grace period ends #{ends_at.strftime('%B %d at %I:%M%p')}" : ""
210
+ if limit_amount.is_a?(Numeric)
211
+ "Heads up! You're currently over your #{noun} for #{limit_key.to_s.humanize.downcase} (#{current_usage}/#{limit_amount})#{deadline}. Please upgrade soon to avoid any interruptions."
212
+ else
213
+ "Heads up! You're currently over your #{noun} for #{limit_key.to_s.humanize.downcase}#{deadline}. Please upgrade soon to avoid any interruptions."
214
+ end
215
+ when :at_limit
216
+ if limit_amount.is_a?(Numeric)
217
+ "You've reached your #{noun} for #{limit_key.to_s.humanize.downcase} (#{current_usage}/#{limit_amount}). Upgrade your plan to unlock more."
218
+ else
219
+ "You're at the maximum allowed for #{limit_key.to_s.humanize.downcase}. Want more? Consider upgrading your plan."
220
+ end
221
+ else # :warning
222
+ if limit_amount.is_a?(Numeric)
223
+ "You're getting close to your #{noun} for #{limit_key.to_s.humanize.downcase} (#{current_usage}/#{limit_amount}). Keep an eye on your usage, or upgrade your plan now to stay ahead."
224
+ else
225
+ "You're getting close to your #{noun} for #{limit_key.to_s.humanize.downcase}. Keep an eye on your usage, or upgrade your plan now to stay ahead."
226
+ end
227
+ end
228
+ end
229
+
230
+ # Overage for a limit
231
+ def overage_for(limit_key)
232
+ st = limit_status(limit_key)
233
+ return 0 unless st[:configured]
234
+
235
+ allowed = st[:limit_amount]
236
+ current = st[:current_usage].to_i
237
+ return 0 unless allowed.is_a?(Numeric)
238
+
239
+ [current - allowed.to_i, 0].max
240
+ end
241
+
242
+ # ========== PRIVATE HELPERS ==========
243
+
244
+ private
245
+
246
+ # Cached enforcement state lookup
247
+ def enforcement_state(limit_key)
248
+ key = limit_key.to_sym
249
+ @enforcement_state_cache ||= {}
250
+ return @enforcement_state_cache[key] if @enforcement_state_cache.key?(key)
251
+
252
+ @enforcement_state_cache[key] = EnforcementState.find_by(
253
+ plan_owner: @plan_owner,
254
+ limit_key: limit_key.to_s
255
+ )
256
+ end
257
+
258
+ # Returns nil if state is stale for the current period window (for per-period limits).
259
+ # Destroys stale states to prevent database accumulation over time.
260
+ def fresh_enforcement_state(limit_key)
261
+ key = limit_key.to_sym
262
+ @fresh_enforcement_state_cache ||= {}
263
+ return @fresh_enforcement_state_cache[key] if @fresh_enforcement_state_cache.key?(key)
264
+
265
+ state = enforcement_state(limit_key)
266
+ return @fresh_enforcement_state_cache[key] = nil unless state
267
+
268
+ limit_config = limit_config_for(limit_key)
269
+ unless limit_config && limit_config[:per]
270
+ return @fresh_enforcement_state_cache[key] = state
271
+ end
272
+
273
+ # For per-period limits, check if state is stale using cached period window
274
+ period_start, _ = period_window_for(limit_key)
275
+ return @fresh_enforcement_state_cache[key] = state unless period_start
276
+
277
+ window_start_epoch = state.data&.dig("window_start_epoch")
278
+ current_epoch = period_start.to_i
279
+
280
+ stale = (state.exceeded_at && state.exceeded_at < period_start) ||
281
+ (window_start_epoch && window_start_epoch < current_epoch) ||
282
+ (window_start_epoch && window_start_epoch != current_epoch)
283
+
284
+ if stale
285
+ # State is stale - destroy it and return nil (consistent with GraceManager behavior)
286
+ state.destroy!
287
+ @fresh_enforcement_state_cache[key] = nil
288
+ else
289
+ @fresh_enforcement_state_cache[key] = state
290
+ end
291
+ end
292
+
293
+ def compute_limit_status(limit_key)
294
+ limit_config = limit_config_for(limit_key)
295
+ return { configured: false } unless limit_config
296
+
297
+ usage = current_usage_for(limit_key)
298
+ limit_amount = limit_config[:to]
299
+ percent = percent_used_for(limit_key)
300
+ grace = grace_active?(limit_key)
301
+ blocked = should_block?(limit_key)
302
+
303
+ {
304
+ configured: true,
305
+ limit_key: limit_key.to_sym,
306
+ limit_amount: limit_amount,
307
+ current_usage: usage,
308
+ percent_used: percent,
309
+ grace_active: grace,
310
+ grace_ends_at: grace_ends_at(limit_key),
311
+ blocked: blocked,
312
+ after_limit: limit_config[:after_limit],
313
+ per: !!limit_config[:per]
314
+ }
315
+ end
316
+
317
+ def compute_severity(limit_key)
318
+ st = limit_status(limit_key)
319
+ return :ok unless st[:configured]
320
+
321
+ lim = st[:limit_amount]
322
+ cur = st[:current_usage]
323
+
324
+ # Grace has priority over other non-blocked statuses
325
+ return :grace if st[:grace_active]
326
+
327
+ # Numeric limit semantics - severity is based on usage vs limit,
328
+ # NOT on the :blocked flag (which is about enforcement, not severity)
329
+ if lim != :unlimited && lim.to_i > 0
330
+ return :blocked if cur.to_i > lim.to_i
331
+ return :at_limit if cur.to_i == lim.to_i
332
+ end
333
+
334
+ # Otherwise, warning based on thresholds
335
+ percent = st[:percent_used].to_f
336
+ warn_thresholds = warning_thresholds(limit_key)
337
+ return :ok if warn_thresholds.empty?
338
+
339
+ highest_warn = warn_thresholds.max.to_f * 100.0
340
+ (percent >= highest_warn && highest_warn.positive?) ? :warning : :ok
341
+ end
342
+ end
343
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PricingPlans
4
- VERSION = "0.2.1"
4
+ VERSION = "0.3.1"
5
5
  end