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,287 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PricingPlans
4
+ # Mix-in for the configured plan owner class (e.g., Organization)
5
+ # Provides readable, owner-centric helpers.
6
+ module PlanOwner
7
+ def self.included(base)
8
+ base.extend(ClassMethods)
9
+ base.singleton_class.prepend HasManyInterceptor
10
+ end
11
+
12
+ # Define English-y sugar methods on the plan owner for a specific limit key.
13
+ # Idempotent: skips if methods already exist.
14
+ def self.define_limit_sugar_methods(plan_owner_class, limit_key)
15
+ key = limit_key.to_sym
16
+ within_m = :"#{key}_within_plan_limits?"
17
+ remaining_m = :"#{key}_remaining"
18
+ percent_m = :"#{key}_percent_used"
19
+ grace_active_m = :"#{key}_grace_active?"
20
+ grace_ends_m = :"#{key}_grace_ends_at"
21
+ blocked_m = :"#{key}_blocked?"
22
+
23
+ unless plan_owner_class.method_defined?(within_m)
24
+ plan_owner_class.define_method(within_m) do |by: 1|
25
+ LimitChecker.within_limit?(self, key, by: by)
26
+ end
27
+ end
28
+
29
+ unless plan_owner_class.method_defined?(remaining_m)
30
+ plan_owner_class.define_method(remaining_m) do
31
+ LimitChecker.plan_limit_remaining(self, key)
32
+ end
33
+ end
34
+
35
+ unless plan_owner_class.method_defined?(percent_m)
36
+ plan_owner_class.define_method(percent_m) do
37
+ LimitChecker.plan_limit_percent_used(self, key)
38
+ end
39
+ end
40
+
41
+ unless plan_owner_class.method_defined?(grace_active_m)
42
+ plan_owner_class.define_method(grace_active_m) do
43
+ GraceManager.grace_active?(self, key)
44
+ end
45
+ end
46
+
47
+ unless plan_owner_class.method_defined?(grace_ends_m)
48
+ plan_owner_class.define_method(grace_ends_m) do
49
+ GraceManager.grace_ends_at(self, key)
50
+ end
51
+ end
52
+
53
+ unless plan_owner_class.method_defined?(blocked_m)
54
+ plan_owner_class.define_method(blocked_m) do
55
+ GraceManager.should_block?(self, key)
56
+ end
57
+ end
58
+ end
59
+
60
+ module HasManyInterceptor
61
+ def has_many(name, scope = nil, **options, &extension)
62
+ limited_opts = options.delete(:limited_by_pricing_plans)
63
+ reflection = super(name, scope, **options, &extension)
64
+
65
+ if limited_opts
66
+ config = limited_opts == true ? {} : limited_opts.dup
67
+ limit_key = (config.delete(:limit_key) || name).to_sym
68
+ per = config.delete(:per)
69
+ error_after_limit = config.delete(:error_after_limit)
70
+ count_scope = config.delete(:count_scope)
71
+
72
+ # Define English-y sugar methods on the plan owner immediately
73
+ PricingPlans::PlanOwner.define_limit_sugar_methods(self, limit_key)
74
+
75
+ begin
76
+ assoc_reflection = reflect_on_association(name)
77
+ child_klass = assoc_reflection.klass
78
+ foreign_key = assoc_reflection.foreign_key.to_s
79
+
80
+ # Find the child's belongs_to backref to this plan owner
81
+ inferred_owner = child_klass.reflections.values.find { |r| r.macro == :belongs_to && r.foreign_key.to_s == foreign_key }&.name
82
+ # If foreign_key doesn't match (e.g., child uses :organization), prefer association matching owner class semantic name
83
+ owner_name_sym = self.name.underscore.to_sym
84
+ inferred_owner ||= (child_klass.reflections.key?(owner_name_sym.to_s) ? owner_name_sym : nil)
85
+ # Common conventions fallback
86
+ inferred_owner ||= %i[organization account user team company workspace tenant].find { |cand| child_klass.reflections.key?(cand.to_s) }
87
+ # Final fallback to underscored class name
88
+ inferred_owner ||= owner_name_sym
89
+
90
+ child_klass.include PricingPlans::Limitable unless child_klass.ancestors.include?(PricingPlans::Limitable)
91
+ child_klass.limited_by_pricing_plans(
92
+ limit_key,
93
+ plan_owner: inferred_owner,
94
+ per: per,
95
+ error_after_limit: error_after_limit,
96
+ count_scope: count_scope
97
+ )
98
+ rescue StandardError
99
+ # If child class cannot be resolved yet, register for later resolution
100
+ PricingPlans::AssociationLimitRegistry.register(
101
+ plan_owner_class: self,
102
+ association_name: name,
103
+ options: { limit_key: limit_key, per: per, error_after_limit: error_after_limit, count_scope: count_scope }
104
+ )
105
+ end
106
+ end
107
+
108
+ reflection
109
+ end
110
+ end
111
+
112
+ module ClassMethods
113
+ end
114
+
115
+ def within_plan_limits?(limit_key, by: 1)
116
+ LimitChecker.within_limit?(self, limit_key, by: by)
117
+ end
118
+
119
+ def plan_limit_remaining(limit_key)
120
+ LimitChecker.plan_limit_remaining(self, limit_key)
121
+ end
122
+
123
+ def plan_limit_percent_used(limit_key)
124
+ LimitChecker.plan_limit_percent_used(self, limit_key)
125
+ end
126
+
127
+ def current_pricing_plan
128
+ PlanResolver.effective_plan_for(self)
129
+ end
130
+
131
+ def on_free_plan?
132
+ plan = current_pricing_plan || PricingPlans::Registry.default_plan
133
+ plan&.free? || false
134
+ end
135
+
136
+ def assign_pricing_plan!(plan_key, source: "manual")
137
+ Assignment.assign_plan_to(self, plan_key, source: source)
138
+ end
139
+
140
+ def remove_pricing_plan!
141
+ Assignment.remove_assignment_for(self)
142
+ end
143
+
144
+ # Features
145
+ def plan_allows?(feature_key)
146
+ plan = current_pricing_plan
147
+ plan&.allows_feature?(feature_key) || false
148
+ end
149
+
150
+ # Syntactic sugar for feature checks:
151
+ # Allows calls like `plan_owner.plan_allows_api_access?` which map to
152
+ # `plan_owner.plan_allows?(:api_access)`.
153
+ def method_missing(method_name, *args, &block)
154
+ if (m = method_name.to_s.match(/^plan_allows_(.+)\?$/))
155
+ feature_key = m[1].to_sym
156
+ return plan_allows?(feature_key)
157
+ end
158
+ super
159
+ end
160
+
161
+ def respond_to_missing?(method_name, include_private = false)
162
+ return true if method_name.to_s.start_with?("plan_allows_") && method_name.to_s.end_with?("?")
163
+ super
164
+ end
165
+
166
+ # Pay (Stripe) convenience wrappers (return false/nil if Pay not available)
167
+ # Pay (Stripe) state — billing-facing, NOT used by our enforcement logic
168
+ def pay_subscription_active?
169
+ PaySupport.subscription_active_for?(self)
170
+ end
171
+
172
+ def pay_on_trial?
173
+ sub = PaySupport.current_subscription_for(self)
174
+ !!(sub && sub.respond_to?(:on_trial?) && sub.on_trial?)
175
+ end
176
+
177
+ def pay_on_grace_period?
178
+ sub = PaySupport.current_subscription_for(self)
179
+ !!(sub && sub.respond_to?(:on_grace_period?) && sub.on_grace_period?)
180
+ end
181
+
182
+ # Per-limit grace helpers managed by PricingPlans
183
+ def grace_active_for?(limit_key)
184
+ GraceManager.grace_active?(self, limit_key)
185
+ end
186
+
187
+ def grace_ends_at_for(limit_key)
188
+ GraceManager.grace_ends_at(self, limit_key)
189
+ end
190
+
191
+ def grace_remaining_seconds_for(limit_key)
192
+ ends_at = grace_ends_at_for(limit_key)
193
+ return 0 unless ends_at
194
+ [0, (ends_at - Time.current).to_i].max
195
+ end
196
+
197
+ def grace_remaining_days_for(limit_key)
198
+ (grace_remaining_seconds_for(limit_key) / 86_400.0).ceil
199
+ end
200
+
201
+ def plan_blocked_for?(limit_key)
202
+ GraceManager.should_block?(self, limit_key)
203
+ end
204
+
205
+ # Aggregate helpers across multiple limit keys
206
+ def any_grace_active_for?(*limit_keys)
207
+ limit_keys.flatten.any? { |k| GraceManager.grace_active?(self, k) }
208
+ end
209
+
210
+ def earliest_grace_ends_at_for(*limit_keys)
211
+ times = limit_keys.flatten.map { |k| GraceManager.grace_ends_at(self, k) }.compact
212
+ times.min
213
+ end
214
+
215
+ # Rails-y wrappers for usage/status (defaults to all configured limits)
216
+ def limit(limit_key)
217
+ PricingPlans.status(self, limits: [limit_key]).first
218
+ end
219
+
220
+ def limits(*limit_keys)
221
+ keys = normalize_limit_keys(limit_keys)
222
+ PricingPlans.status(self, limits: keys)
223
+ end
224
+
225
+ def limits_summary(*limit_keys)
226
+ limits(*limit_keys)
227
+ end
228
+
229
+ def limits_severity(*limit_keys)
230
+ keys = normalize_limit_keys(limit_keys)
231
+ PricingPlans.highest_severity_for(self, *keys)
232
+ end
233
+
234
+ def limits_message(*limit_keys)
235
+ keys = normalize_limit_keys(limit_keys)
236
+ PricingPlans.combine_messages_for(self, *keys)
237
+ end
238
+
239
+ # Global overview for banner building
240
+ def limits_overview(*limit_keys)
241
+ keys = normalize_limit_keys(limit_keys)
242
+ PricingPlans.overview_for(self, *keys)
243
+ end
244
+
245
+ # View-friendly, English-like convenience helpers (pure data decisions)
246
+ def limit_severity(limit_key)
247
+ PricingPlans.severity_for(self, limit_key)
248
+ end
249
+
250
+ def limit_message(limit_key)
251
+ PricingPlans.message_for(self, limit_key)
252
+ end
253
+
254
+ def limit_overage(limit_key)
255
+ PricingPlans.overage_for(self, limit_key)
256
+ end
257
+
258
+ def attention_required_for_limit?(limit_key)
259
+ PricingPlans.attention_required?(self, limit_key)
260
+ end
261
+
262
+ def approaching_limit?(limit_key, at: nil)
263
+ PricingPlans.approaching_limit?(self, limit_key, at: at)
264
+ end
265
+
266
+ # Returns { text:, url: }
267
+ def plan_cta
268
+ PricingPlans.cta_for(self)
269
+ end
270
+
271
+ # Returns the pure-data alert view model for a single limit key
272
+ def limit_alert(limit_key)
273
+ PricingPlans.alert_for(self, limit_key)
274
+ end
275
+
276
+ private
277
+
278
+ def normalize_limit_keys(limit_keys)
279
+ keys = limit_keys.flatten
280
+ if keys.empty?
281
+ plan = PlanResolver.effective_plan_for(self)
282
+ keys = plan ? plan.limits.keys : []
283
+ end
284
+ keys
285
+ end
286
+ end
287
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PricingPlans
4
+ class PlanResolver
5
+ class << self
6
+ def effective_plan_for(plan_owner)
7
+ # 1. Check Pay subscription status first (no app-specific gate required)
8
+ if PaySupport.pay_available?
9
+ plan_from_pay = resolve_plan_from_pay(plan_owner)
10
+ return plan_from_pay if plan_from_pay
11
+ end
12
+
13
+ # 2. Check manual assignment
14
+ if plan_owner.respond_to?(:id)
15
+ assignment = Assignment.find_by(
16
+ plan_owner_type: plan_owner.class.name,
17
+ plan_owner_id: plan_owner.id
18
+ )
19
+ return Registry.plan(assignment.plan_key) if assignment
20
+ end
21
+
22
+ # 3. Fall back to default plan
23
+ Registry.default_plan
24
+ end
25
+
26
+ def plan_key_for(plan_owner)
27
+ effective_plan_for(plan_owner)&.key
28
+ end
29
+
30
+ def assign_plan_manually!(plan_owner, plan_key, source: "manual")
31
+ Assignment.assign_plan_to(plan_owner, plan_key, source: source)
32
+ end
33
+
34
+ def remove_manual_assignment!(plan_owner)
35
+ Assignment.remove_assignment_for(plan_owner)
36
+ end
37
+
38
+ private
39
+
40
+ # Backward-compatible shim for tests that stub pay_available?
41
+ def pay_available?
42
+ PaySupport.pay_available?
43
+ end
44
+
45
+ def resolve_plan_from_pay(plan_owner)
46
+ return nil unless plan_owner.respond_to?(:subscribed?) ||
47
+ plan_owner.respond_to?(:on_trial?) ||
48
+ plan_owner.respond_to?(:on_grace_period?) ||
49
+ plan_owner.respond_to?(:subscriptions)
50
+
51
+ # Check if plan_owner has active subscription, trial, or grace period
52
+ if PaySupport.subscription_active_for?(plan_owner)
53
+ subscription = PaySupport.current_subscription_for(plan_owner)
54
+ return nil unless subscription
55
+
56
+ # Map processor plan to our plan
57
+ processor_plan = subscription.processor_plan
58
+ return plan_from_processor_plan(processor_plan) if processor_plan
59
+ end
60
+
61
+ nil
62
+ end
63
+
64
+ def plan_from_processor_plan(processor_plan)
65
+ # Look through all plans to find one matching this Stripe price
66
+ Registry.plans.values.find do |plan|
67
+ stripe_price = plan.stripe_price
68
+ next unless stripe_price
69
+
70
+ case stripe_price
71
+ when String
72
+ stripe_price == processor_plan
73
+ when Hash
74
+ stripe_price[:id] == processor_plan ||
75
+ stripe_price[:month] == processor_plan ||
76
+ stripe_price[:year] == processor_plan ||
77
+ stripe_price.values.include?(processor_plan)
78
+ else
79
+ false
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PricingPlans
4
+ # Pure-data value object describing a plan price in semantic parts.
5
+ # UI-agnostic. Useful to render classic pricing typography and power JS toggles.
6
+ PriceComponents = Struct.new(
7
+ :present?, # boolean: true when numeric price is available
8
+ :currency, # String: currency symbol, e.g. "$", "€"
9
+ :amount, # String: human whole amount (no decimals) e.g. "29"
10
+ :amount_cents, # Integer: total cents e.g. 2900
11
+ :interval, # Symbol: :month or :year
12
+ :label, # String: friendly label e.g. "$29/mo" or "Contact"
13
+ :monthly_equivalent_cents, # Integer: same-month or yearly/12 rounded
14
+ keyword_init: true
15
+ )
16
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PricingPlans
4
+ class Registry
5
+ class << self
6
+ def build_from_configuration(configuration)
7
+ @plans = configuration.plans.dup
8
+ @configuration = configuration
9
+ @event_handlers = configuration.event_handlers.dup
10
+
11
+ validate_registry!
12
+ lint_usage_credits_integration!
13
+ attach_plan_owner_helpers!
14
+ attach_pending_association_limits!
15
+
16
+ self
17
+ end
18
+
19
+ def clear!
20
+ @plans = nil
21
+ @configuration = nil
22
+ @event_handlers = nil
23
+ end
24
+
25
+ def plans
26
+ @plans || {}
27
+ end
28
+
29
+ def plan(key)
30
+ plan_obj = plans[key.to_sym]
31
+ raise PlanNotFoundError, "Plan #{key} not found" unless plan_obj
32
+ plan_obj
33
+ end
34
+
35
+ def plan_exists?(key)
36
+ plans.key?(key.to_sym)
37
+ end
38
+
39
+ def configuration
40
+ @configuration
41
+ end
42
+
43
+ def event_handlers
44
+ @event_handlers || { warning: {}, grace_start: {}, block: {} }
45
+ end
46
+
47
+ def plan_owner_class
48
+ return nil unless @configuration
49
+
50
+ value = @configuration.plan_owner_class
51
+ return nil unless value
52
+
53
+ case value
54
+ when String
55
+ value.constantize
56
+ when Class
57
+ value
58
+ else
59
+ raise ConfigurationError, "plan_owner_class must be a string or class"
60
+ end
61
+ end
62
+
63
+ def default_plan
64
+ return nil unless @configuration
65
+ plan(@configuration.default_plan)
66
+ end
67
+
68
+ def highlighted_plan
69
+ return nil unless @configuration
70
+ if @configuration.highlighted_plan
71
+ return plan(@configuration.highlighted_plan)
72
+ end
73
+ # Fallback to plan flagged highlighted in DSL
74
+ plans.values.find(&:highlighted?)
75
+ end
76
+
77
+ def emit_event(event_type, limit_key, *args)
78
+ handler = event_handlers.dig(event_type, limit_key)
79
+ handler&.call(*args)
80
+ end
81
+
82
+ private
83
+
84
+ def validate_registry!
85
+ # Check for duplicate stripe price IDs
86
+ stripe_prices = plans.values
87
+ .map(&:stripe_price)
88
+ .compact
89
+ .flat_map do |sp|
90
+ case sp
91
+ when String
92
+ [sp]
93
+ when Hash
94
+ # Extract all price ID values from the hash
95
+ [sp[:id], sp[:month], sp[:year]].compact
96
+ else
97
+ []
98
+ end
99
+ end
100
+
101
+ duplicates = stripe_prices.group_by(&:itself).select { |_, v| v.size > 1 }.keys
102
+ if duplicates.any?
103
+ raise ConfigurationError, "Duplicate Stripe price IDs found: #{duplicates.join(', ')}"
104
+ end
105
+
106
+ # Validate limit configurations
107
+ validate_limit_consistency!
108
+ end
109
+
110
+ def attach_plan_owner_helpers!
111
+ klass = plan_owner_class rescue nil
112
+ return unless klass
113
+ return if klass.included_modules.include?(PricingPlans::PlanOwner)
114
+ klass.include(PricingPlans::PlanOwner)
115
+ rescue StandardError
116
+ # If plan_owner class isn't available yet, skip silently.
117
+ end
118
+
119
+ def attach_pending_association_limits!
120
+ PricingPlans::AssociationLimitRegistry.flush_pending!
121
+ end
122
+
123
+ def validate_limit_consistency!
124
+ all_limits = plans.values.flat_map do |plan|
125
+ plan.limits.map { |key, limit| [plan.key, key, limit] }
126
+ end
127
+
128
+ # Group by limit key to check consistency
129
+ limit_groups = all_limits.group_by { |_, limit_key, _| limit_key }
130
+
131
+ limit_groups.each do |limit_key, limit_configs|
132
+ # Filter out unlimited limits from consistency check
133
+ non_unlimited_configs = limit_configs.reject { |_, _, limit| limit[:to] == :unlimited }
134
+
135
+ # Check that all non-unlimited plans with the same limit key use consistent per: configuration
136
+ per_values = non_unlimited_configs.map { |_, _, limit| limit[:per] }.uniq
137
+
138
+ # Remove nil values to check if there are mixed per/non-per configurations
139
+ non_nil_per_values = per_values.compact
140
+
141
+ # If we have both nil and non-nil per values, that's inconsistent
142
+ # If we have multiple different non-nil per values, that's also inconsistent
143
+ has_nil = per_values.include?(nil)
144
+ has_non_nil = non_nil_per_values.any?
145
+
146
+ if (has_nil && has_non_nil) || non_nil_per_values.size > 1
147
+ raise ConfigurationError,
148
+ "Inconsistent 'per' configuration for limit '#{limit_key}': #{per_values.compact}"
149
+ end
150
+ end
151
+ end
152
+
153
+ def usage_credits_available?
154
+ defined?(UsageCredits)
155
+ end
156
+
157
+ def lint_usage_credits_integration!
158
+ # With single-currency credits, we only enforce separation of concerns:
159
+ # - pricing_plans shows declared total credits per plan (cosmetic)
160
+ # - usage_credits owns operations, costs, fulfillment, and spending
161
+ # There is no per-operation credits declaration here anymore.
162
+ # Still enforce that if you choose to model a metered dimension as credits in your app,
163
+ # you should not also define a per-period limit with the same semantic key.
164
+ credit_operation_keys = if usage_credits_available?
165
+ UsageCredits.registry.operations.keys.map(&:to_sym) rescue []
166
+ else
167
+ []
168
+ end
169
+
170
+ plans.each do |_plan_key, plan|
171
+ plan.limits.each do |limit_key, limit|
172
+ next unless limit[:per] # Only per-period limits
173
+ if credit_operation_keys.include?(limit_key.to_sym)
174
+ raise ConfigurationError,
175
+ "Limit '#{limit_key}' is also a usage_credits operation. Use credits (usage_credits) OR a per-period limit (pricing_plans), not both."
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PricingPlans
4
+ class Result
5
+ STATES = [:within, :warning, :grace, :blocked].freeze
6
+
7
+ attr_reader :state, :message, :limit_key, :plan_owner, :metadata
8
+
9
+ def initialize(state:, message:, limit_key: nil, plan_owner: nil, metadata: {})
10
+ unless STATES.include?(state)
11
+ raise ArgumentError, "Invalid state: #{state}. Must be one of: #{STATES}"
12
+ end
13
+
14
+ @state = state
15
+ @message = message
16
+ @limit_key = limit_key
17
+ @plan_owner = plan_owner
18
+ @metadata = metadata
19
+ end
20
+
21
+ def ok?
22
+ @state == :within
23
+ end
24
+
25
+ alias_method :within?, :ok?
26
+
27
+ def warning?
28
+ @state == :warning
29
+ end
30
+
31
+ def grace?
32
+ @state == :grace
33
+ end
34
+
35
+ def blocked?
36
+ @state == :blocked
37
+ end
38
+
39
+ def success?
40
+ ok? || warning? || grace?
41
+ end
42
+
43
+ def failure?
44
+ blocked?
45
+ end
46
+
47
+ # Helper methods for view rendering
48
+ def css_class
49
+ case @state
50
+ when :within
51
+ "success"
52
+ when :warning
53
+ "warning"
54
+ when :grace
55
+ "warning"
56
+ when :blocked
57
+ "error"
58
+ end
59
+ end
60
+
61
+ def icon
62
+ case @state
63
+ when :within
64
+ "✓"
65
+ when :warning
66
+ "⚠"
67
+ when :grace
68
+ "⏳"
69
+ when :blocked
70
+ "🚫"
71
+ end
72
+ end
73
+
74
+ def to_h
75
+ {
76
+ state: @state,
77
+ message: @message,
78
+ limit_key: @limit_key,
79
+ metadata: @metadata,
80
+ ok: ok?,
81
+ warning: warning?,
82
+ grace: grace?,
83
+ blocked: blocked?
84
+ }
85
+ end
86
+
87
+ def inspect
88
+ "#<PricingPlans::Result state=#{@state} message=\"#{@message}\">"
89
+ end
90
+
91
+ class << self
92
+ def within(message = "Within limit", **options)
93
+ new(state: :within, message: message, **options)
94
+ end
95
+
96
+ def warning(message, **options)
97
+ new(state: :warning, message: message, **options)
98
+ end
99
+
100
+ def grace(message, **options)
101
+ new(state: :grace, message: message, **options)
102
+ end
103
+
104
+ def blocked(message, **options)
105
+ new(state: :blocked, message: message, **options)
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PricingPlans
4
+ VERSION = "0.1.0"
5
+ end