pricing_plans 0.2.1 → 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.
@@ -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)
@@ -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.0"
5
5
  end
data/lib/pricing_plans.rb CHANGED
@@ -28,6 +28,7 @@ module PricingPlans
28
28
  autoload :LimitChecker, "pricing_plans/limit_checker"
29
29
  autoload :LimitableRegistry, "pricing_plans/limit_checker"
30
30
  autoload :GraceManager, "pricing_plans/grace_manager"
31
+ autoload :Callbacks, "pricing_plans/callbacks"
31
32
  autoload :PeriodCalculator, "pricing_plans/period_calculator"
32
33
  autoload :ControllerGuards, "pricing_plans/controller_guards"
33
34
  autoload :JobGuards, "pricing_plans/job_guards"
@@ -39,6 +40,7 @@ module PricingPlans
39
40
  autoload :OverageReporter, "pricing_plans/overage_reporter"
40
41
  autoload :PriceComponents, "pricing_plans/price_components"
41
42
  autoload :ViewHelpers, "pricing_plans/view_helpers"
43
+ autoload :StatusContext, "pricing_plans/status_context"
42
44
 
43
45
  # Models
44
46
  autoload :EnforcementState, "pricing_plans/models/enforcement_state"
@@ -227,8 +229,12 @@ module PricingPlans
227
229
  )
228
230
 
229
231
  def status(plan_owner, limits: [])
232
+ # Use StatusContext to cache all computed values and eliminate N+1 queries.
233
+ # Each call to status() gets its own context - thread-safe by design.
234
+ ctx = StatusContext.new(plan_owner)
235
+
230
236
  items = Array(limits).map do |limit_key|
231
- st = limit_status(limit_key, plan_owner: plan_owner)
237
+ st = ctx.limit_status(limit_key)
232
238
  if !st[:configured]
233
239
  StatusItem.new(
234
240
  key: limit_key,
@@ -257,7 +263,7 @@ module PricingPlans
257
263
  period_seconds_remaining: nil
258
264
  )
259
265
  else
260
- sev = severity_for(plan_owner, limit_key)
266
+ sev = ctx.severity_for(limit_key)
261
267
  allowed = st[:limit_amount]
262
268
  current = st[:current_usage].to_i
263
269
  unlimited = (allowed == :unlimited)
@@ -266,7 +272,7 @@ module PricingPlans
266
272
  else
267
273
  nil
268
274
  end
269
- warn_thresholds = LimitChecker.warning_thresholds(plan_owner, limit_key)
275
+ warn_thresholds = ctx.warning_thresholds(limit_key)
270
276
  percent = st[:percent_used].to_f
271
277
  next_warn = begin
272
278
  thresholds = warn_thresholds.map { |t| t.to_f * 100.0 }.uniq.sort
@@ -277,7 +283,7 @@ module PricingPlans
277
283
  period_seconds_remaining = nil
278
284
  if st[:per]
279
285
  begin
280
- period_start, period_end = PeriodCalculator.window_for(plan_owner, limit_key)
286
+ period_start, period_end = ctx.period_window_for(limit_key)
281
287
  if period_end
282
288
  period_seconds_remaining = [0, (period_end - Time.current).to_i].max
283
289
  end
@@ -312,8 +318,8 @@ module PricingPlans
312
318
  when :blocked then 4
313
319
  else 0
314
320
  end,
315
- message: (sev == :ok ? nil : message_for(plan_owner, limit_key)),
316
- overage: overage_for(plan_owner, limit_key),
321
+ message: (sev == :ok ? nil : ctx.message_for(limit_key)),
322
+ overage: ctx.overage_for(limit_key),
317
323
  configured: true,
318
324
  unlimited: unlimited,
319
325
  remaining: remaining,
@@ -329,12 +335,12 @@ module PricingPlans
329
335
  end
330
336
  end
331
337
 
332
- # Compute and attach overall helpers directly on the returned array
338
+ # Compute overall helpers using context (all values already cached)
333
339
  keys = items.map(&:key)
334
- sev = highest_severity_for(plan_owner, *keys)
340
+ sev = ctx.highest_severity_for(*keys)
335
341
  title = summary_title_for(sev)
336
- msg = summary_message_for(plan_owner, *keys, severity: sev)
337
- highest_keys = keys.select { |k| severity_for(plan_owner, k) == sev }
342
+ msg = compute_summary_message_from_context(ctx, keys, sev)
343
+ highest_keys = keys.select { |k| ctx.severity_for(k) == sev }
338
344
  highest_limits = items.select { |it| highest_keys.include?(it.key) }
339
345
  human_keys = highest_keys.map { |k| k.to_s.humanize.downcase }
340
346
  keys_sentence = if human_keys.respond_to?(:to_sentence)
@@ -648,5 +654,39 @@ module PricingPlans
648
654
  def popular_plan_key
649
655
  highlighted_plan_key
650
656
  end
657
+
658
+ private
659
+
660
+ # Helper for status() that builds summary message using cached context data
661
+ def compute_summary_message_from_context(ctx, keys, sev)
662
+ return nil if keys.empty?
663
+ return nil if sev == :ok
664
+
665
+ affected = keys.select { |k| ctx.severity_for(k) == sev }
666
+ human_keys = affected.map { |k| k.to_s.humanize.downcase }
667
+ keys_list = if human_keys.respond_to?(:to_sentence)
668
+ human_keys.to_sentence
669
+ else
670
+ if human_keys.length <= 2
671
+ human_keys.join(" and ")
672
+ else
673
+ human_keys[0..-2].join(", ") + " and " + human_keys[-1]
674
+ end
675
+ end
676
+ noun = affected.size == 1 ? "plan limit" : "plan limits"
677
+
678
+ case sev
679
+ when :blocked
680
+ "Your #{noun} for #{keys_list} #{affected.size == 1 ? "has" : "have"} been exceeded. Please upgrade to continue."
681
+ when :grace
682
+ grace_end = keys.map { |k| ctx.grace_ends_at(k) }.compact.min
683
+ suffix = grace_end ? ", grace active until #{grace_end}" : ""
684
+ "You are over your #{noun} for #{keys_list}#{suffix}. Please upgrade to avoid service disruption."
685
+ when :at_limit
686
+ "You have reached your #{noun} for #{keys_list}."
687
+ else # :warning
688
+ "You are approaching your #{noun} for #{keys_list}."
689
+ end
690
+ end
651
691
  end
652
692
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pricing_plans
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-01-15 00:00:00.000000000 Z
10
+ date: 2026-02-15 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activerecord
@@ -61,8 +61,9 @@ executables: []
61
61
  extensions: []
62
62
  extra_rdoc_files: []
63
63
  files:
64
- - ".claude/settings.local.json"
65
64
  - ".rubocop.yml"
65
+ - ".simplecov"
66
+ - AGENTS.md
66
67
  - Appraisals
67
68
  - CHANGELOG.md
68
69
  - CLAUDE.md
@@ -86,6 +87,7 @@ files:
86
87
  - lib/generators/pricing_plans/install/templates/initializer.rb
87
88
  - lib/pricing_plans.rb
88
89
  - lib/pricing_plans/association_limit_registry.rb
90
+ - lib/pricing_plans/callbacks.rb
89
91
  - lib/pricing_plans/configuration.rb
90
92
  - lib/pricing_plans/controller_guards.rb
91
93
  - lib/pricing_plans/controller_rescues.rb
@@ -108,6 +110,7 @@ files:
108
110
  - lib/pricing_plans/price_components.rb
109
111
  - lib/pricing_plans/registry.rb
110
112
  - lib/pricing_plans/result.rb
113
+ - lib/pricing_plans/status_context.rb
111
114
  - lib/pricing_plans/version.rb
112
115
  - lib/pricing_plans/view_helpers.rb
113
116
  - sig/pricing_plans.rbs
@@ -1,19 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(sed:*)",
5
- "Bash(grep:*)",
6
- "Bash(find:*)",
7
- "mcp__context7__resolve-library-id",
8
- "mcp__context7__get-library-docs",
9
- "WebFetch(domain:github.com)",
10
- "WebSearch",
11
- "Bash(bundle exec rake test:*)",
12
- "Bash(bundle install:*)",
13
- "Bash(bundle exec appraisal:*)",
14
- "Bash(ls:*)",
15
- "Bash(done)"
16
- ],
17
- "deny": []
18
- }
19
- }