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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/settings.local.json +16 -0
  3. data/.rubocop.yml +137 -0
  4. data/CHANGELOG.md +83 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +241 -0
  7. data/Rakefile +15 -0
  8. data/docs/01-define-pricing-plans.md +372 -0
  9. data/docs/02-controller-helpers.md +223 -0
  10. data/docs/03-model-helpers.md +318 -0
  11. data/docs/04-views.md +121 -0
  12. data/docs/05-semantic-pricing.md +159 -0
  13. data/docs/06-gem-compatibility.md +99 -0
  14. data/docs/images/pricing_plans_ruby_rails_gem_pricing_table.jpg +0 -0
  15. data/docs/images/pricing_plans_ruby_rails_gem_usage_alert_upgrade.jpg +0 -0
  16. data/docs/images/pricing_plans_ruby_rails_gem_usage_meter.jpg +0 -0
  17. data/docs/images/product_creation_blocked.jpg +0 -0
  18. data/lib/generators/pricing_plans/install/install_generator.rb +42 -0
  19. data/lib/generators/pricing_plans/install/templates/create_pricing_plans_tables.rb.erb +91 -0
  20. data/lib/generators/pricing_plans/install/templates/initializer.rb +100 -0
  21. data/lib/pricing_plans/association_limit_registry.rb +45 -0
  22. data/lib/pricing_plans/configuration.rb +189 -0
  23. data/lib/pricing_plans/controller_guards.rb +574 -0
  24. data/lib/pricing_plans/controller_rescues.rb +115 -0
  25. data/lib/pricing_plans/dsl.rb +44 -0
  26. data/lib/pricing_plans/engine.rb +69 -0
  27. data/lib/pricing_plans/grace_manager.rb +227 -0
  28. data/lib/pricing_plans/integer_refinements.rb +48 -0
  29. data/lib/pricing_plans/job_guards.rb +24 -0
  30. data/lib/pricing_plans/limit_checker.rb +157 -0
  31. data/lib/pricing_plans/limitable.rb +286 -0
  32. data/lib/pricing_plans/models/assignment.rb +55 -0
  33. data/lib/pricing_plans/models/enforcement_state.rb +45 -0
  34. data/lib/pricing_plans/models/usage.rb +51 -0
  35. data/lib/pricing_plans/overage_reporter.rb +77 -0
  36. data/lib/pricing_plans/pay_support.rb +85 -0
  37. data/lib/pricing_plans/period_calculator.rb +183 -0
  38. data/lib/pricing_plans/plan.rb +653 -0
  39. data/lib/pricing_plans/plan_owner.rb +287 -0
  40. data/lib/pricing_plans/plan_resolver.rb +85 -0
  41. data/lib/pricing_plans/price_components.rb +16 -0
  42. data/lib/pricing_plans/registry.rb +182 -0
  43. data/lib/pricing_plans/result.rb +109 -0
  44. data/lib/pricing_plans/version.rb +5 -0
  45. data/lib/pricing_plans/view_helpers.rb +58 -0
  46. data/lib/pricing_plans.rb +645 -0
  47. data/sig/pricing_plans.rbs +4 -0
  48. 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