pricing_plans 0.1.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 +7 -0
- data/.claude/settings.local.json +16 -0
- data/.rubocop.yml +137 -0
- data/CHANGELOG.md +83 -0
- data/LICENSE.txt +21 -0
- data/README.md +241 -0
- data/Rakefile +15 -0
- data/docs/01-define-pricing-plans.md +372 -0
- data/docs/02-controller-helpers.md +223 -0
- data/docs/03-model-helpers.md +318 -0
- data/docs/04-views.md +121 -0
- data/docs/05-semantic-pricing.md +159 -0
- data/docs/06-gem-compatibility.md +99 -0
- data/docs/images/pricing_plans_ruby_rails_gem_pricing_table.jpg +0 -0
- data/docs/images/pricing_plans_ruby_rails_gem_usage_alert_upgrade.jpg +0 -0
- data/docs/images/pricing_plans_ruby_rails_gem_usage_meter.jpg +0 -0
- data/docs/images/product_creation_blocked.jpg +0 -0
- data/lib/generators/pricing_plans/install/install_generator.rb +42 -0
- data/lib/generators/pricing_plans/install/templates/create_pricing_plans_tables.rb.erb +91 -0
- data/lib/generators/pricing_plans/install/templates/initializer.rb +100 -0
- data/lib/pricing_plans/association_limit_registry.rb +45 -0
- data/lib/pricing_plans/configuration.rb +189 -0
- data/lib/pricing_plans/controller_guards.rb +574 -0
- data/lib/pricing_plans/controller_rescues.rb +115 -0
- data/lib/pricing_plans/dsl.rb +44 -0
- data/lib/pricing_plans/engine.rb +69 -0
- data/lib/pricing_plans/grace_manager.rb +227 -0
- data/lib/pricing_plans/integer_refinements.rb +48 -0
- data/lib/pricing_plans/job_guards.rb +24 -0
- data/lib/pricing_plans/limit_checker.rb +157 -0
- data/lib/pricing_plans/limitable.rb +286 -0
- data/lib/pricing_plans/models/assignment.rb +55 -0
- data/lib/pricing_plans/models/enforcement_state.rb +45 -0
- data/lib/pricing_plans/models/usage.rb +51 -0
- data/lib/pricing_plans/overage_reporter.rb +77 -0
- data/lib/pricing_plans/pay_support.rb +85 -0
- data/lib/pricing_plans/period_calculator.rb +183 -0
- data/lib/pricing_plans/plan.rb +653 -0
- data/lib/pricing_plans/plan_owner.rb +287 -0
- data/lib/pricing_plans/plan_resolver.rb +85 -0
- data/lib/pricing_plans/price_components.rb +16 -0
- data/lib/pricing_plans/registry.rb +182 -0
- data/lib/pricing_plans/result.rb +109 -0
- data/lib/pricing_plans/version.rb +5 -0
- data/lib/pricing_plans/view_helpers.rb +58 -0
- data/lib/pricing_plans.rb +645 -0
- data/sig/pricing_plans.rbs +4 -0
- metadata +236 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PricingPlans
|
|
4
|
+
module DSL
|
|
5
|
+
# This module provides common DSL functionality that can be included
|
|
6
|
+
# in other classes to provide a consistent interface
|
|
7
|
+
|
|
8
|
+
# Period constants for easy reference
|
|
9
|
+
PERIOD_OPTIONS = [
|
|
10
|
+
:billing_cycle,
|
|
11
|
+
:calendar_month,
|
|
12
|
+
:calendar_week,
|
|
13
|
+
:calendar_day,
|
|
14
|
+
:month,
|
|
15
|
+
:week,
|
|
16
|
+
:day
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def validate_period_option(period)
|
|
22
|
+
return true if period.respond_to?(:call) # Custom callable
|
|
23
|
+
return true if PERIOD_OPTIONS.include?(period)
|
|
24
|
+
|
|
25
|
+
# Allow ActiveSupport duration objects
|
|
26
|
+
return true if period.respond_to?(:seconds)
|
|
27
|
+
|
|
28
|
+
false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def normalize_period(period)
|
|
32
|
+
case period
|
|
33
|
+
when :month
|
|
34
|
+
:calendar_month
|
|
35
|
+
when :week
|
|
36
|
+
:calendar_week
|
|
37
|
+
when :day
|
|
38
|
+
:calendar_day
|
|
39
|
+
else
|
|
40
|
+
period
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PricingPlans
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace PricingPlans
|
|
6
|
+
|
|
7
|
+
initializer "pricing_plans.active_record" do
|
|
8
|
+
ActiveSupport.on_load(:active_record) do
|
|
9
|
+
# Make models available
|
|
10
|
+
require "pricing_plans/models/enforcement_state"
|
|
11
|
+
require "pricing_plans/models/usage"
|
|
12
|
+
require "pricing_plans/models/assignment"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
initializer "pricing_plans.action_controller" do
|
|
17
|
+
ActiveSupport.on_load(:action_controller) do
|
|
18
|
+
# Include controller guards in ApplicationController
|
|
19
|
+
include PricingPlans::ControllerGuards
|
|
20
|
+
# Install a sensible default rescue for feature gating so apps get 403 by default.
|
|
21
|
+
# Apps can override by defining their own rescue_from in their controllers.
|
|
22
|
+
include PricingPlans::ControllerRescues if defined?(PricingPlans::ControllerRescues)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Support API-only apps (ActionController::API)
|
|
27
|
+
initializer "pricing_plans.action_controller_api" do
|
|
28
|
+
ActiveSupport.on_load(:action_controller_api) do
|
|
29
|
+
include PricingPlans::ControllerGuards
|
|
30
|
+
include PricingPlans::ControllerRescues if defined?(PricingPlans::ControllerRescues)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Include view helpers (pure-data, no HTML opinions)
|
|
35
|
+
initializer "pricing_plans.action_view" do
|
|
36
|
+
ActiveSupport.on_load(:action_view) do
|
|
37
|
+
include PricingPlans::ViewHelpers if defined?(PricingPlans::ViewHelpers)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Ensure the configured plan owner class (e.g., Organization) gains the
|
|
42
|
+
# owner-centric helpers even if the model is not loaded during
|
|
43
|
+
# configuration time. Runs on each code reload in dev.
|
|
44
|
+
initializer "pricing_plans.plan_owner_helpers" do
|
|
45
|
+
ActiveSupport::Reloader.to_prepare do
|
|
46
|
+
begin
|
|
47
|
+
klass = PricingPlans::Registry.plan_owner_class
|
|
48
|
+
if klass && !klass.included_modules.include?(PricingPlans::PlanOwner)
|
|
49
|
+
klass.include(PricingPlans::PlanOwner)
|
|
50
|
+
end
|
|
51
|
+
rescue StandardError
|
|
52
|
+
# If the plan owner class isn't resolved yet, skip; next reload will try again.
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Add generator paths
|
|
58
|
+
config.generators do |g|
|
|
59
|
+
g.templates.unshift File.expand_path("../../generators", __dir__)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Map FeatureDenied to HTTP 403 by default so unhandled exceptions don't become 500s.
|
|
63
|
+
initializer "pricing_plans.rescue_responses" do |app|
|
|
64
|
+
app.config.action_dispatch.rescue_responses.merge!(
|
|
65
|
+
"PricingPlans::FeatureDenied" => :forbidden
|
|
66
|
+
) if app.config.respond_to?(:action_dispatch) && app.config.action_dispatch.respond_to?(:rescue_responses)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PricingPlans
|
|
4
|
+
class GraceManager
|
|
5
|
+
class << self
|
|
6
|
+
def mark_exceeded!(plan_owner, limit_key, grace_period: nil)
|
|
7
|
+
with_lock(plan_owner, limit_key) do |state|
|
|
8
|
+
# Ensure state is for the current window for per-period limits
|
|
9
|
+
state = ensure_fresh_state_for_current_window!(state, plan_owner, limit_key)
|
|
10
|
+
|
|
11
|
+
return state if state.exceeded?
|
|
12
|
+
|
|
13
|
+
plan = PlanResolver.effective_plan_for(plan_owner)
|
|
14
|
+
limit_config = plan&.limit_for(limit_key)
|
|
15
|
+
|
|
16
|
+
grace_period ||= limit_config&.dig(:grace) || 7.days
|
|
17
|
+
|
|
18
|
+
state.update!(
|
|
19
|
+
exceeded_at: Time.current,
|
|
20
|
+
data: state.data.merge(
|
|
21
|
+
"grace_period" => grace_period.to_i,
|
|
22
|
+
|
|
23
|
+
# Track window for per-period limits
|
|
24
|
+
"window_start_epoch" => current_window_start_if_per(limit_config, plan_owner, limit_key)&.to_i,
|
|
25
|
+
"window_end_epoch" => current_window_end_if_per(limit_config, plan_owner, limit_key)&.to_i
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Emit grace start event
|
|
30
|
+
emit_grace_start_event(plan_owner, limit_key, state.grace_ends_at)
|
|
31
|
+
|
|
32
|
+
state
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def grace_active?(plan_owner, limit_key)
|
|
37
|
+
state = fresh_state_or_nil(plan_owner, limit_key)
|
|
38
|
+
return false unless state&.exceeded?
|
|
39
|
+
!state.grace_expired?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def should_block?(plan_owner, limit_key)
|
|
43
|
+
plan = PlanResolver.effective_plan_for(plan_owner)
|
|
44
|
+
limit_config = plan&.limit_for(limit_key)
|
|
45
|
+
return false unless limit_config
|
|
46
|
+
|
|
47
|
+
after_limit = limit_config[:after_limit]
|
|
48
|
+
return false if after_limit == :just_warn
|
|
49
|
+
|
|
50
|
+
# Only block when usage has reached or exceeded the configured limit
|
|
51
|
+
limit_amount = limit_config[:to]
|
|
52
|
+
return false if limit_amount == :unlimited
|
|
53
|
+
current_usage = LimitChecker.current_usage_for(plan_owner, limit_key, limit_config)
|
|
54
|
+
exceeded = current_usage >= limit_amount.to_i
|
|
55
|
+
# Treat 0-of-0 as not blocked for UX/severity/status purposes
|
|
56
|
+
exceeded = false if limit_amount.to_i.zero? && current_usage.to_i.zero?
|
|
57
|
+
|
|
58
|
+
return exceeded if after_limit == :block_usage
|
|
59
|
+
|
|
60
|
+
# For :grace_then_block, check if grace period expired
|
|
61
|
+
state = fresh_state_or_nil(plan_owner, limit_key)
|
|
62
|
+
return false unless state&.exceeded?
|
|
63
|
+
|
|
64
|
+
state.grace_expired?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def mark_blocked!(plan_owner, limit_key)
|
|
68
|
+
with_lock(plan_owner, limit_key) do |state|
|
|
69
|
+
state = ensure_fresh_state_for_current_window!(state, plan_owner, limit_key)
|
|
70
|
+
return state if state.blocked?
|
|
71
|
+
|
|
72
|
+
state.update!(blocked_at: Time.current)
|
|
73
|
+
|
|
74
|
+
# Emit block event
|
|
75
|
+
emit_block_event(plan_owner, limit_key)
|
|
76
|
+
|
|
77
|
+
state
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def maybe_emit_warning!(plan_owner, limit_key, threshold)
|
|
82
|
+
with_lock(plan_owner, limit_key) do |state|
|
|
83
|
+
state = ensure_fresh_state_for_current_window!(state, plan_owner, limit_key)
|
|
84
|
+
last_threshold = state.last_warning_threshold || 0.0
|
|
85
|
+
|
|
86
|
+
# Only emit if this is a higher threshold than last time
|
|
87
|
+
if threshold > last_threshold
|
|
88
|
+
plan = PlanResolver.effective_plan_for(plan_owner)
|
|
89
|
+
limit_config = plan&.limit_for(limit_key)
|
|
90
|
+
window_start_epoch = nil
|
|
91
|
+
window_end_epoch = nil
|
|
92
|
+
if limit_config && limit_config[:per]
|
|
93
|
+
period_start, period_end = PeriodCalculator.window_for(plan_owner, limit_key)
|
|
94
|
+
window_start_epoch = period_start.to_i
|
|
95
|
+
window_end_epoch = period_end.to_i
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
state.update!(
|
|
99
|
+
last_warning_threshold: threshold,
|
|
100
|
+
last_warning_at: Time.current,
|
|
101
|
+
data: state.data.merge(
|
|
102
|
+
"window_start_epoch" => window_start_epoch,
|
|
103
|
+
"window_end_epoch" => window_end_epoch
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
emit_warning_event(plan_owner, limit_key, threshold)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
state
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def reset_state!(plan_owner, limit_key)
|
|
115
|
+
state = find_state(plan_owner, limit_key)
|
|
116
|
+
return unless state
|
|
117
|
+
|
|
118
|
+
state.destroy!
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def grace_ends_at(plan_owner, limit_key)
|
|
122
|
+
state = find_state(plan_owner, limit_key)
|
|
123
|
+
state&.grace_ends_at
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def with_lock(plan_owner, limit_key)
|
|
129
|
+
|
|
130
|
+
# Use row-level locking to prevent race conditions
|
|
131
|
+
state = nil
|
|
132
|
+
begin
|
|
133
|
+
state = EnforcementState.lock.find_or_create_by!(
|
|
134
|
+
plan_owner: plan_owner,
|
|
135
|
+
limit_key: limit_key.to_s
|
|
136
|
+
) { |new_state| new_state.data = {} }
|
|
137
|
+
rescue ActiveRecord::RecordNotUnique
|
|
138
|
+
# Concurrent creation; fetch the locked row and proceed
|
|
139
|
+
state = EnforcementState.lock.find_by!(plan_owner: plan_owner, limit_key: limit_key.to_s)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Retry logic for deadlocks
|
|
143
|
+
retries = 0
|
|
144
|
+
begin
|
|
145
|
+
yield(state)
|
|
146
|
+
rescue ActiveRecord::Deadlocked, ActiveRecord::LockWaitTimeout => e
|
|
147
|
+
retries += 1
|
|
148
|
+
if retries < 3
|
|
149
|
+
sleep(0.1 * retries)
|
|
150
|
+
retry
|
|
151
|
+
else
|
|
152
|
+
raise e
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def find_state(plan_owner, limit_key)
|
|
158
|
+
EnforcementState.find_by(plan_owner: plan_owner, limit_key: limit_key.to_s)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Returns nil if state is stale for the current period window for per-period limits
|
|
162
|
+
def fresh_state_or_nil(plan_owner, limit_key)
|
|
163
|
+
state = find_state(plan_owner, limit_key)
|
|
164
|
+
return nil unless state
|
|
165
|
+
|
|
166
|
+
plan = PlanResolver.effective_plan_for(plan_owner)
|
|
167
|
+
limit_config = plan&.limit_for(limit_key)
|
|
168
|
+
return state unless limit_config && limit_config[:per]
|
|
169
|
+
|
|
170
|
+
period_start, _ = PeriodCalculator.window_for(plan_owner, limit_key)
|
|
171
|
+
window_start_epoch = state.data&.dig("window_start_epoch")
|
|
172
|
+
current_epoch = period_start.to_i
|
|
173
|
+
|
|
174
|
+
if stale_for_window?(state, period_start, window_start_epoch, current_epoch)
|
|
175
|
+
state.destroy!
|
|
176
|
+
return nil
|
|
177
|
+
end
|
|
178
|
+
state
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def stale_for_window?(state, period_start, window_start_epoch, current_epoch)
|
|
182
|
+
(state.exceeded_at && state.exceeded_at < period_start) ||
|
|
183
|
+
(window_start_epoch && window_start_epoch < current_epoch) ||
|
|
184
|
+
(window_start_epoch && window_start_epoch != current_epoch)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def emit_warning_event(plan_owner, limit_key, threshold)
|
|
188
|
+
Registry.emit_event(:warning, limit_key.to_sym, plan_owner, threshold)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def emit_grace_start_event(plan_owner, limit_key, grace_ends_at)
|
|
192
|
+
Registry.emit_event(:grace_start, limit_key.to_sym, plan_owner, grace_ends_at)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def emit_block_event(plan_owner, limit_key)
|
|
196
|
+
Registry.emit_event(:block, limit_key.to_sym, plan_owner)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# Ensure the state aligns with the current period window for per-period limits
|
|
201
|
+
def ensure_fresh_state_for_current_window!(state, plan_owner, limit_key)
|
|
202
|
+
plan = PlanResolver.effective_plan_for(plan_owner)
|
|
203
|
+
limit_config = plan&.limit_for(limit_key)
|
|
204
|
+
return state unless limit_config && limit_config[:per]
|
|
205
|
+
|
|
206
|
+
period_start, _ = PeriodCalculator.window_for(plan_owner, limit_key)
|
|
207
|
+
window_start_epoch = state.data&.dig("window_start_epoch")
|
|
208
|
+
current_epoch = period_start.to_i
|
|
209
|
+
if stale_for_window?(state, period_start, window_start_epoch, current_epoch)
|
|
210
|
+
state.destroy!
|
|
211
|
+
state = EnforcementState.lock.find_or_create_by!(plan_owner: plan_owner, limit_key: limit_key.to_s) { |new_state| new_state.data = {} }
|
|
212
|
+
end
|
|
213
|
+
state
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def current_window_start_if_per(limit_config, plan_owner, limit_key)
|
|
217
|
+
return nil unless limit_config && limit_config[:per]
|
|
218
|
+
PeriodCalculator.window_for(plan_owner, limit_key).first
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def current_window_end_if_per(limit_config, plan_owner, limit_key)
|
|
222
|
+
return nil unless limit_config && limit_config[:per]
|
|
223
|
+
PeriodCalculator.window_for(plan_owner, limit_key).last
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PricingPlans
|
|
4
|
+
# Refinements for Integer to provide DSL sugar like `5.max`
|
|
5
|
+
# This is scoped only to our DSL usage to avoid polluting the global namespace
|
|
6
|
+
module IntegerRefinements
|
|
7
|
+
refine Integer do
|
|
8
|
+
def max
|
|
9
|
+
self
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Additional convenience methods for time periods that read well in DSL
|
|
13
|
+
alias_method :day, :day if method_defined?(:day)
|
|
14
|
+
alias_method :days, :days if method_defined?(:days)
|
|
15
|
+
alias_method :week, :week if method_defined?(:week)
|
|
16
|
+
alias_method :weeks, :weeks if method_defined?(:weeks)
|
|
17
|
+
alias_method :month, :month if method_defined?(:month)
|
|
18
|
+
alias_method :months, :months if method_defined?(:months)
|
|
19
|
+
|
|
20
|
+
# If ActiveSupport isn't loaded, provide basic duration support
|
|
21
|
+
unless method_defined?(:days)
|
|
22
|
+
def days
|
|
23
|
+
self * 86400 # seconds in a day
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def day
|
|
27
|
+
days
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def weeks
|
|
31
|
+
days * 7
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def week
|
|
35
|
+
weeks
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def months
|
|
39
|
+
days * 30 # approximate for basic support
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def month
|
|
43
|
+
months
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PricingPlans
|
|
4
|
+
# Lightweight ergonomics for background jobs and services
|
|
5
|
+
module JobGuards
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# Runs the given block only if within limit or when system override is allowed.
|
|
9
|
+
# Returns the Result in all cases so callers can inspect state.
|
|
10
|
+
# Usage:
|
|
11
|
+
# PricingPlans::JobGuards.with_plan_limit(:licenses, plan_owner: org, by: 1, allow_system_override: true) do |result|
|
|
12
|
+
# # perform work; result.warning?/grace? can be surfaced
|
|
13
|
+
# end
|
|
14
|
+
def with_plan_limit(limit_key, plan_owner:, by: 1, allow_system_override: false)
|
|
15
|
+
result = ControllerGuards.require_plan_limit!(limit_key, plan_owner: plan_owner, by: by, allow_system_override: allow_system_override)
|
|
16
|
+
|
|
17
|
+
blocked_without_override = result.blocked? && !(allow_system_override && result.metadata && result.metadata[:system_override])
|
|
18
|
+
return result if blocked_without_override
|
|
19
|
+
|
|
20
|
+
yield(result) if block_given?
|
|
21
|
+
result
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PricingPlans
|
|
4
|
+
class LimitChecker
|
|
5
|
+
class << self
|
|
6
|
+
# English-y aliases used widely across helpers/tests
|
|
7
|
+
def plan_limit_remaining(plan_owner, limit_key)
|
|
8
|
+
remaining(plan_owner, limit_key)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def plan_limit_percent_used(plan_owner, limit_key)
|
|
12
|
+
percent_used(plan_owner, limit_key)
|
|
13
|
+
end
|
|
14
|
+
def within_limit?(plan_owner, limit_key, by: 1)
|
|
15
|
+
remaining_amount = remaining(plan_owner, limit_key)
|
|
16
|
+
return true if remaining_amount == :unlimited
|
|
17
|
+
remaining_amount >= by
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def remaining(plan_owner, limit_key)
|
|
21
|
+
plan = PlanResolver.effective_plan_for(plan_owner)
|
|
22
|
+
limit_config = plan&.limit_for(limit_key)
|
|
23
|
+
return :unlimited unless limit_config
|
|
24
|
+
|
|
25
|
+
limit_amount = limit_config[:to]
|
|
26
|
+
return :unlimited if limit_amount == :unlimited
|
|
27
|
+
|
|
28
|
+
current_usage = current_usage_for(plan_owner, limit_key, limit_config)
|
|
29
|
+
[0, limit_amount - current_usage].max
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def percent_used(plan_owner, limit_key)
|
|
33
|
+
plan = PlanResolver.effective_plan_for(plan_owner)
|
|
34
|
+
limit_config = plan&.limit_for(limit_key)
|
|
35
|
+
return 0.0 unless limit_config
|
|
36
|
+
|
|
37
|
+
limit_amount = limit_config[:to]
|
|
38
|
+
return 0.0 if limit_amount == :unlimited || limit_amount.zero?
|
|
39
|
+
|
|
40
|
+
current_usage = current_usage_for(plan_owner, limit_key, limit_config)
|
|
41
|
+
[(current_usage.to_f / limit_amount) * 100, 100.0].min
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Keep short helpers undocumented; public API is plan_limit_* aliases
|
|
45
|
+
|
|
46
|
+
def after_limit_action(plan_owner, limit_key)
|
|
47
|
+
plan = PlanResolver.effective_plan_for(plan_owner)
|
|
48
|
+
limit_config = plan&.limit_for(limit_key)
|
|
49
|
+
return :block_usage unless limit_config
|
|
50
|
+
|
|
51
|
+
limit_config[:after_limit]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def limit_amount(plan_owner, limit_key)
|
|
55
|
+
plan = PlanResolver.effective_plan_for(plan_owner)
|
|
56
|
+
limit_config = plan&.limit_for(limit_key)
|
|
57
|
+
return :unlimited unless limit_config
|
|
58
|
+
|
|
59
|
+
limit_config[:to]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def current_usage_for(plan_owner, limit_key, limit_config = nil)
|
|
63
|
+
limit_config ||= begin
|
|
64
|
+
plan = PlanResolver.effective_plan_for(plan_owner)
|
|
65
|
+
plan&.limit_for(limit_key)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
return 0 unless limit_config
|
|
69
|
+
|
|
70
|
+
if limit_config[:per]
|
|
71
|
+
# Per-period allowance - check usage table
|
|
72
|
+
per_period_usage(plan_owner, limit_key)
|
|
73
|
+
else
|
|
74
|
+
# Persistent cap - count live objects
|
|
75
|
+
persistent_usage(plan_owner, limit_key)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def warning_thresholds(plan_owner, limit_key)
|
|
80
|
+
plan = PlanResolver.effective_plan_for(plan_owner)
|
|
81
|
+
limit_config = plan&.limit_for(limit_key)
|
|
82
|
+
return [] unless limit_config
|
|
83
|
+
|
|
84
|
+
limit_config[:warn_at] || []
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def should_warn?(plan_owner, limit_key)
|
|
88
|
+
percent = percent_used(plan_owner, limit_key)
|
|
89
|
+
thresholds = warning_thresholds(plan_owner, limit_key)
|
|
90
|
+
|
|
91
|
+
# Find the highest threshold that has been crossed
|
|
92
|
+
crossed_threshold = thresholds.select { |t| percent >= (t * 100) }.max
|
|
93
|
+
return nil unless crossed_threshold
|
|
94
|
+
|
|
95
|
+
# Check if we've already warned for this threshold
|
|
96
|
+
state = enforcement_state(plan_owner, limit_key)
|
|
97
|
+
last_threshold = state&.last_warning_threshold
|
|
98
|
+
|
|
99
|
+
# Return the threshold if this is a new higher threshold, nil otherwise
|
|
100
|
+
crossed_threshold > (last_threshold || 0) ? crossed_threshold : nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def per_period_usage(plan_owner, limit_key)
|
|
106
|
+
period_start, period_end = PeriodCalculator.window_for(plan_owner, limit_key)
|
|
107
|
+
|
|
108
|
+
usage = Usage.find_by(
|
|
109
|
+
plan_owner: plan_owner,
|
|
110
|
+
limit_key: limit_key.to_s,
|
|
111
|
+
period_start: period_start,
|
|
112
|
+
period_end: period_end
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
usage&.used || 0
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def persistent_usage(plan_owner, limit_key)
|
|
119
|
+
# This is provided by the Limitable mixin, which registers per-model counters
|
|
120
|
+
# keyed by limit key. When declared via has_many limited_by_pricing_plans, the
|
|
121
|
+
# child model registers the counter as well.
|
|
122
|
+
counter = LimitableRegistry.counter_for(limit_key)
|
|
123
|
+
return 0 unless counter
|
|
124
|
+
|
|
125
|
+
counter.call(plan_owner)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def enforcement_state(plan_owner, limit_key)
|
|
129
|
+
EnforcementState.find_by(
|
|
130
|
+
plan_owner: plan_owner,
|
|
131
|
+
limit_key: limit_key.to_s
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Registry for Limitable counters
|
|
138
|
+
class LimitableRegistry
|
|
139
|
+
class << self
|
|
140
|
+
def register_counter(limit_key, &block)
|
|
141
|
+
counters[limit_key.to_sym] = block
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def counter_for(limit_key)
|
|
145
|
+
counters[limit_key.to_sym]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def counters
|
|
149
|
+
@counters ||= {}
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def clear!
|
|
153
|
+
@counters = {}
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|