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.
- checksums.yaml +4 -4
- data/.simplecov +35 -0
- data/AGENTS.md +5 -0
- data/CHANGELOG.md +9 -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 +70 -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
|
@@ -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)
|
|
@@ -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
|
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
|
|
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(
|
|
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 =
|
|
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 =
|
|
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(
|
|
316
|
-
overage: overage_for(
|
|
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
|
|
338
|
+
# Compute overall helpers using context (all values already cached)
|
|
333
339
|
keys = items.map(&:key)
|
|
334
|
-
sev = highest_severity_for(
|
|
340
|
+
sev = ctx.highest_severity_for(*keys)
|
|
335
341
|
title = summary_title_for(sev)
|
|
336
|
-
msg =
|
|
337
|
-
highest_keys = keys.select { |k| severity_for(
|
|
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.
|
|
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-
|
|
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
|
data/.claude/settings.local.json
DELETED
|
@@ -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
|
-
}
|