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.
- checksums.yaml +4 -4
- data/.simplecov +35 -0
- data/AGENTS.md +5 -0
- data/CHANGELOG.md +14 -1
- data/CLAUDE.md +5 -1
- data/README.md +2 -2
- data/docs/01-define-pricing-plans.md +104 -15
- data/docs/03-model-helpers.md +59 -0
- data/gemfiles/rails_7.2.gemfile +6 -4
- data/gemfiles/rails_8.1.gemfile +6 -4
- data/lib/generators/pricing_plans/install/templates/initializer.rb +38 -4
- data/lib/pricing_plans/callbacks.rb +152 -0
- data/lib/pricing_plans/configuration.rb +18 -6
- data/lib/pricing_plans/limitable.rb +38 -1
- data/lib/pricing_plans/models/enforcement_state.rb +1 -1
- data/lib/pricing_plans/period_calculator.rb +6 -0
- data/lib/pricing_plans/plan_owner.rb +80 -0
- data/lib/pricing_plans/plan_resolver.rb +13 -13
- data/lib/pricing_plans/registry.rb +2 -2
- data/lib/pricing_plans/status_context.rb +343 -0
- data/lib/pricing_plans/version.rb +1 -1
- data/lib/pricing_plans.rb +50 -10
- metadata +6 -3
- data/.claude/settings.local.json +0 -19
|
@@ -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 :
|
|
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
|
|
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
|
-
|
|
79
|
-
|
|
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
|