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,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PricingPlans
4
+ module ViewHelpers
5
+ module_function
6
+
7
+ # Pure-data UI struct for Stimulus/JS pricing toggles
8
+ # Returns keys:
9
+ # - :monthly_price, :yearly_price (formatted labels)
10
+ # - :monthly_price_cents, :yearly_price_cents
11
+ # - :monthly_price_id, :yearly_price_id
12
+ # - :free (boolean)
13
+ # - :label (fallback label for non-numeric)
14
+ def pricing_plan_ui_data(plan)
15
+ pc_m = plan.monthly_price_components
16
+ pc_y = plan.yearly_price_components
17
+ {
18
+ monthly_price: pc_m.label,
19
+ yearly_price: pc_y.label,
20
+ monthly_price_cents: pc_m.amount_cents,
21
+ yearly_price_cents: pc_y.amount_cents,
22
+ monthly_price_id: plan.monthly_price_id,
23
+ yearly_price_id: plan.yearly_price_id,
24
+ free: plan.free?,
25
+ label: plan.price_label
26
+ }
27
+ end
28
+
29
+ # CTA data resolution. Returns pure data: { text:, url:, method:, disabled:, reason: }
30
+ # We keep this minimal and policy-free by default; host apps can layer policies.
31
+ def pricing_plan_cta(plan, plan_owner: nil, context: :marketing, current_plan: nil)
32
+ text = plan.cta_text
33
+ url = plan.cta_url(plan_owner: plan_owner)
34
+ url ||= pricing_plans_subscribe_path(plan)
35
+ disabled = false
36
+ reason = nil
37
+
38
+ if current_plan && plan.key.to_sym == current_plan.key.to_sym
39
+ disabled = true
40
+ text = "Current Plan"
41
+ end
42
+
43
+ { text: text, url: url, method: :get, disabled: disabled, reason: reason }
44
+ end
45
+
46
+ # Helper that resolves the conventional subscribe path if present in host app
47
+ # Defaults to monthly interval; apps can override by adding interval param in links
48
+ def pricing_plans_subscribe_path(plan, interval: :month)
49
+ if respond_to?(:main_app) && main_app.respond_to?(:subscribe_path)
50
+ return main_app.subscribe_path(plan: plan.key, interval: interval)
51
+ end
52
+ if defined?(Rails) && Rails.application.routes.url_helpers.respond_to?(:subscribe_path)
53
+ return Rails.application.routes.url_helpers.subscribe_path(plan: plan.key, interval: interval)
54
+ end
55
+ nil
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,645 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pricing_plans/version"
4
+ require_relative "pricing_plans/engine" if defined?(Rails::Engine)
5
+
6
+ module PricingPlans
7
+ class Error < StandardError; end
8
+ class ConfigurationError < Error; end
9
+ class PlanNotFoundError < Error; end
10
+ class FeatureDenied < Error
11
+ attr_reader :feature_key, :plan_owner
12
+
13
+ def initialize(message = nil, feature_key: nil, plan_owner: nil)
14
+ super(message)
15
+ @feature_key = feature_key
16
+ @plan_owner = plan_owner
17
+ end
18
+ end
19
+ class InvalidOperation < Error; end
20
+
21
+ autoload :Configuration, "pricing_plans/configuration"
22
+ autoload :Registry, "pricing_plans/registry"
23
+ autoload :Plan, "pricing_plans/plan"
24
+ autoload :DSL, "pricing_plans/dsl"
25
+ autoload :IntegerRefinements, "pricing_plans/integer_refinements"
26
+ autoload :PlanResolver, "pricing_plans/plan_resolver"
27
+ autoload :PaySupport, "pricing_plans/pay_support"
28
+ autoload :LimitChecker, "pricing_plans/limit_checker"
29
+ autoload :LimitableRegistry, "pricing_plans/limit_checker"
30
+ autoload :GraceManager, "pricing_plans/grace_manager"
31
+ autoload :PeriodCalculator, "pricing_plans/period_calculator"
32
+ autoload :ControllerGuards, "pricing_plans/controller_guards"
33
+ autoload :JobGuards, "pricing_plans/job_guards"
34
+ autoload :ControllerRescues, "pricing_plans/controller_rescues"
35
+ autoload :Limitable, "pricing_plans/limitable"
36
+ autoload :PlanOwner, "pricing_plans/plan_owner"
37
+ autoload :AssociationLimitRegistry, "pricing_plans/association_limit_registry"
38
+ autoload :Result, "pricing_plans/result"
39
+ autoload :OverageReporter, "pricing_plans/overage_reporter"
40
+ autoload :PriceComponents, "pricing_plans/price_components"
41
+ autoload :ViewHelpers, "pricing_plans/view_helpers"
42
+
43
+ # Models
44
+ autoload :EnforcementState, "pricing_plans/models/enforcement_state"
45
+ autoload :Usage, "pricing_plans/models/usage"
46
+ autoload :Assignment, "pricing_plans/models/assignment"
47
+
48
+ class << self
49
+ attr_writer :configuration
50
+
51
+ def configuration
52
+ @configuration ||= Configuration.new
53
+ end
54
+
55
+ def configure(&block)
56
+ # Support both styles simultaneously inside the block:
57
+ # - Bare DSL: plan :free { ... }
58
+ # - Explicit: config.plan :free { ... }
59
+ # We evaluate the block with self = configuration, while also
60
+ # passing the configuration object as the first block parameter.
61
+ # Evaluate with self = configuration and also pass the configuration
62
+ # object as a block parameter for explicit calls (config.plan ...).
63
+ configuration.instance_exec(configuration, &block) if block
64
+ configuration.validate!
65
+ Registry.build_from_configuration(configuration)
66
+ end
67
+
68
+ def reset_configuration!
69
+ @configuration = nil
70
+ Registry.clear!
71
+ LimitableRegistry.clear!
72
+ end
73
+
74
+ def registry
75
+ Registry
76
+ end
77
+
78
+ # Zero-shim Plans API for host apps
79
+ # Returns an array of Plan objects in a sensible order (free → paid → enterprise/contact)
80
+ def plans
81
+ array = Registry.plans.values
82
+ array.sort_by do |p|
83
+ # Free first, then numeric price ascending, then price_string/stripe-price at the end
84
+ if p.price && p.price.to_f.zero?
85
+ 0
86
+ elsif p.price
87
+ 1 + p.price.to_f
88
+ else
89
+ 10_000 # price_string or stripe_price (enterprise/contact) last
90
+ end
91
+ end
92
+ end
93
+
94
+ # Single, UI-neutral helper for pricing pages.
95
+ # Returns an array of Hashes containing plain data for building pricing UIs.
96
+ # Each item includes: :key, :name, :description, :bullets, :price_label,
97
+ # :is_current, :is_popular, :button_text, :button_url
98
+ def for_pricing(plan_owner: nil, view: nil)
99
+ plans.map { |plan| decorate_for_view(plan, plan_owner: plan_owner, view: view) }
100
+ end
101
+
102
+ # View model for modern UIs (Stimulus/Hotwire/JSON). Pure data.
103
+ # Uses the new semantic pricing API on Plan (price_components and Stripe accessors).
104
+ def view_models
105
+ plans.map { |p| p.to_view_model }
106
+ end
107
+
108
+ # Opinionated next-plan suggestion: pick the smallest plan that satisfies current usage
109
+ def suggest_next_plan_for(plan_owner, keys: nil)
110
+ current_plan = PlanResolver.effective_plan_for(plan_owner)
111
+ sorted = plans
112
+ keys ||= (current_plan&.limits&.keys || [])
113
+ keys = keys.map(&:to_sym)
114
+
115
+ candidate = sorted.find do |plan|
116
+ if current_plan && current_plan.price && plan.price && plan.price.to_f < current_plan.price.to_f
117
+ next false
118
+ end
119
+ keys.all? do |key|
120
+ limit = plan.limit_for(key)
121
+ next true unless limit
122
+ limit[:to] == :unlimited || LimitChecker.current_usage_for(plan_owner, key, limit) <= limit[:to].to_i
123
+ end
124
+ end
125
+ candidate || current_plan || Registry.default_plan
126
+ end
127
+
128
+ # Optional view-model decorator for UIs (pure data, no HTML)
129
+ def decorate_for_view(plan, plan_owner: nil, view: nil)
130
+ is_current = plan_owner ? (PlanResolver.effective_plan_for(plan_owner)&.key == plan.key) : false
131
+ is_popular = Registry.highlighted_plan&.key == plan.key
132
+ price_label = plan_price_label_for(plan)
133
+ {
134
+ key: plan.key,
135
+ name: plan.name,
136
+ description: plan.description,
137
+ bullets: plan.bullets,
138
+ price_label: price_label,
139
+ is_current: is_current,
140
+ is_popular: is_popular,
141
+ button_text: plan.cta_text,
142
+ button_url: plan.cta_url(plan_owner: plan_owner)
143
+ }
144
+ end
145
+
146
+ # Derive a human price label for a plan
147
+ def plan_price_label_for(plan)
148
+ return "Free" if plan.price && plan.price.to_i.zero?
149
+ return plan.price_string if plan.price_string
150
+ return "$#{plan.price}/mo" if plan.price
151
+ return "Contact" if plan.stripe_price || plan.price.nil?
152
+ nil
153
+ end
154
+
155
+ # Minimal noun resolver for human messages. Defaults to "limit" to match
156
+ # expected copy in tests and docs. Host apps may override this method.
157
+ def noun_for(limit_key)
158
+ "limit"
159
+ end
160
+
161
+ # UI-neutral status helpers for building settings/usage UIs
162
+ def limit_status(limit_key, plan_owner:)
163
+ plan = PlanResolver.effective_plan_for(plan_owner)
164
+ limit_config = plan&.limit_for(limit_key)
165
+ return { configured: false } unless limit_config
166
+
167
+ usage = LimitChecker.current_usage_for(plan_owner, limit_key, limit_config)
168
+ limit_amount = limit_config[:to]
169
+ percent = LimitChecker.plan_limit_percent_used(plan_owner, limit_key)
170
+ grace = GraceManager.grace_active?(plan_owner, limit_key)
171
+ blocked = GraceManager.should_block?(plan_owner, limit_key)
172
+
173
+ {
174
+ configured: true,
175
+ limit_key: limit_key.to_sym,
176
+ limit_amount: limit_amount,
177
+ current_usage: usage,
178
+ percent_used: percent,
179
+ grace_active: grace,
180
+ grace_ends_at: GraceManager.grace_ends_at(plan_owner, limit_key),
181
+ blocked: blocked,
182
+ after_limit: limit_config[:after_limit],
183
+ per: !!limit_config[:per]
184
+ }
185
+ end
186
+
187
+ def limit_statuses(*limit_keys, plan_owner:)
188
+ keys = limit_keys.flatten
189
+ keys.index_with { |k| limit_status(k, plan_owner: plan_owner) }
190
+ end
191
+
192
+ # Unified, pure-data status item for a single limit key
193
+ # Includes raw usage, gating flags, and view-friendly severity/message
194
+ StatusItem = Struct.new(
195
+ :key,
196
+ :human_key,
197
+ :current,
198
+ :allowed,
199
+ :percent_used,
200
+ :grace_active,
201
+ :grace_ends_at,
202
+ :blocked,
203
+ :per,
204
+ :severity,
205
+ :severity_level,
206
+ :message,
207
+ :overage,
208
+ :configured,
209
+ :unlimited,
210
+ :remaining,
211
+ :after_limit,
212
+ :attention?,
213
+ :next_creation_blocked?,
214
+ :warn_thresholds,
215
+ :next_warn_percent,
216
+ :period_start,
217
+ :period_end,
218
+ :period_seconds_remaining,
219
+ keyword_init: true
220
+ )
221
+
222
+ def status(plan_owner, limits: [])
223
+ items = Array(limits).map do |limit_key|
224
+ st = limit_status(limit_key, plan_owner: plan_owner)
225
+ if !st[:configured]
226
+ StatusItem.new(
227
+ key: limit_key,
228
+ human_key: limit_key.to_s.humanize.downcase,
229
+ current: 0,
230
+ allowed: nil,
231
+ percent_used: 0.0,
232
+ grace_active: false,
233
+ grace_ends_at: nil,
234
+ blocked: false,
235
+ per: false,
236
+ severity: :ok,
237
+ severity_level: 0,
238
+ message: nil,
239
+ overage: 0,
240
+ configured: false,
241
+ unlimited: false,
242
+ remaining: nil,
243
+ after_limit: nil,
244
+ attention?: false,
245
+ next_creation_blocked?: false,
246
+ warn_thresholds: [],
247
+ next_warn_percent: nil,
248
+ period_start: nil,
249
+ period_end: nil,
250
+ period_seconds_remaining: nil
251
+ )
252
+ else
253
+ sev = severity_for(plan_owner, limit_key)
254
+ allowed = st[:limit_amount]
255
+ current = st[:current_usage].to_i
256
+ unlimited = (allowed == :unlimited)
257
+ remaining = if allowed.is_a?(Numeric)
258
+ [allowed.to_i - current, 0].max
259
+ else
260
+ nil
261
+ end
262
+ warn_thresholds = LimitChecker.warning_thresholds(plan_owner, limit_key)
263
+ percent = st[:percent_used].to_f
264
+ next_warn = begin
265
+ thresholds = warn_thresholds.map { |t| t.to_f * 100.0 }.uniq.sort
266
+ thresholds.find { |t| t > percent }
267
+ end
268
+ period_start = nil
269
+ period_end = nil
270
+ period_seconds_remaining = nil
271
+ if st[:per]
272
+ begin
273
+ period_start, period_end = PeriodCalculator.window_for(plan_owner, limit_key)
274
+ if period_end
275
+ period_seconds_remaining = [0, (period_end - Time.current).to_i].max
276
+ end
277
+ rescue StandardError
278
+ # ignore period window resolution errors in status
279
+ end
280
+ end
281
+ next_creation_blocked = case sev
282
+ when :blocked
283
+ true
284
+ when :at_limit
285
+ st[:after_limit] == :block_usage
286
+ else
287
+ false
288
+ end
289
+ StatusItem.new(
290
+ key: limit_key,
291
+ human_key: limit_key.to_s.humanize.downcase,
292
+ current: current,
293
+ allowed: allowed,
294
+ percent_used: st[:percent_used],
295
+ grace_active: st[:grace_active],
296
+ grace_ends_at: st[:grace_ends_at],
297
+ blocked: st[:blocked],
298
+ per: st[:per],
299
+ severity: sev,
300
+ severity_level: case sev
301
+ when :ok then 0
302
+ when :warning then 1
303
+ when :at_limit then 2
304
+ when :grace then 3
305
+ when :blocked then 4
306
+ else 0
307
+ end,
308
+ message: (sev == :ok ? nil : message_for(plan_owner, limit_key)),
309
+ overage: overage_for(plan_owner, limit_key),
310
+ configured: true,
311
+ unlimited: unlimited,
312
+ remaining: remaining,
313
+ after_limit: st[:after_limit],
314
+ attention?: sev != :ok,
315
+ next_creation_blocked?: next_creation_blocked,
316
+ warn_thresholds: warn_thresholds,
317
+ next_warn_percent: next_warn,
318
+ period_start: period_start,
319
+ period_end: period_end,
320
+ period_seconds_remaining: period_seconds_remaining
321
+ )
322
+ end
323
+ end
324
+
325
+ # Compute and attach overall helpers directly on the returned array
326
+ keys = items.map(&:key)
327
+ sev = highest_severity_for(plan_owner, *keys)
328
+ title = summary_title_for(sev)
329
+ msg = summary_message_for(plan_owner, *keys, severity: sev)
330
+ highest_keys = keys.select { |k| severity_for(plan_owner, k) == sev }
331
+ highest_limits = items.select { |it| highest_keys.include?(it.key) }
332
+ human_keys = highest_keys.map { |k| k.to_s.humanize.downcase }
333
+ keys_sentence = if human_keys.respond_to?(:to_sentence)
334
+ human_keys.to_sentence
335
+ else
336
+ human_keys.length <= 2 ? human_keys.join(" and ") : (human_keys[0..-2].join(", ") + " and " + human_keys[-1])
337
+ end
338
+ noun = highest_keys.size == 1 ? "plan limit" : "plan limits"
339
+ has_have = highest_keys.size == 1 ? "has" : "have"
340
+ cta = cta_for_upgrade(plan_owner)
341
+
342
+ sev_level = case sev
343
+ when :ok then 0
344
+ when :warning then 1
345
+ when :at_limit then 2
346
+ when :grace then 3
347
+ when :blocked then 4
348
+ else 0
349
+ end
350
+
351
+ items.define_singleton_method(:overall_severity) { sev }
352
+ items.define_singleton_method(:overall_severity_level) { sev_level }
353
+ items.define_singleton_method(:overall_title) { title }
354
+ items.define_singleton_method(:overall_message) { msg }
355
+ items.define_singleton_method(:overall_attention?) { sev != :ok }
356
+ items.define_singleton_method(:overall_keys) { keys }
357
+ items.define_singleton_method(:overall_highest_keys) { highest_keys }
358
+ items.define_singleton_method(:overall_highest_limits) { highest_limits }
359
+ items.define_singleton_method(:overall_keys_sentence) { keys_sentence }
360
+ items.define_singleton_method(:overall_noun) { noun }
361
+ items.define_singleton_method(:overall_has_have) { has_have }
362
+ items.define_singleton_method(:overall_cta_text) { cta[:text] }
363
+ items.define_singleton_method(:overall_cta_url) { cta[:url] }
364
+
365
+ items
366
+ end
367
+
368
+ # Aggregates across multiple limits for global banners/messages
369
+ # Returns one of :ok, :warning, :at_limit, :grace, :blocked
370
+ def highest_severity_for(plan_owner, *limit_keys)
371
+ keys = limit_keys.flatten
372
+ per_key = keys.map do |key|
373
+ st = limit_status(key, plan_owner: plan_owner)
374
+ next :ok unless st[:configured]
375
+
376
+ lim = st[:limit_amount]
377
+ cur = st[:current_usage]
378
+
379
+ # Grace has priority over other non-blocked statuses
380
+ return :grace if st[:grace_active]
381
+
382
+ # Numeric limit semantics
383
+ if lim != :unlimited && lim.to_i > 0
384
+ return :blocked if cur.to_i > lim.to_i
385
+ return :at_limit if cur.to_i == lim.to_i
386
+ end
387
+
388
+ # Otherwise, warning based on thresholds
389
+ percent = st[:percent_used].to_f
390
+ warn_thresholds = LimitChecker.warning_thresholds(plan_owner, key)
391
+ highest_warn = warn_thresholds.max.to_f * 100.0
392
+ (percent >= highest_warn && highest_warn.positive?) ? :warning : :ok
393
+ end
394
+ return :at_limit if per_key.include?(:at_limit)
395
+ per_key.include?(:warning) ? :warning : :ok
396
+ end
397
+
398
+ # Global overview for multiple keys for easy banner building.
399
+ # Returns: { severity:, severity_level:, title:, message:, attention?:, keys:, highest_keys:, highest_limits:, keys_sentence:, noun:, has_have:, cta_text:, cta_url: }
400
+ def overview_for(plan_owner, *limit_keys)
401
+ keys = limit_keys.flatten
402
+ items = status(plan_owner, limits: keys)
403
+ {
404
+ severity: items.overall_severity,
405
+ severity_level: items.overall_severity_level,
406
+ title: items.overall_title,
407
+ message: items.overall_message,
408
+ attention?: items.overall_attention?,
409
+ keys: items.overall_keys,
410
+ highest_keys: items.overall_highest_keys,
411
+ highest_limits: items.overall_highest_limits,
412
+ keys_sentence: items.overall_keys_sentence,
413
+ noun: items.overall_noun,
414
+ has_have: items.overall_has_have,
415
+ cta_text: items.overall_cta_text,
416
+ cta_url: items.overall_cta_url
417
+ }
418
+ end
419
+
420
+ # Human title for overall banner based on severity
421
+ def summary_title_for(severity)
422
+ case severity
423
+ when :blocked then "Plan limit reached"
424
+ when :grace then "Over limit — grace active"
425
+ when :at_limit then "At your plan limit"
426
+ when :warning then "Approaching plan limit"
427
+ else "All good"
428
+ end
429
+ end
430
+
431
+ # Short, humanized summary for multiple keys
432
+ # Builds copy using only the keys at the highest severity
433
+ def summary_message_for(plan_owner, *limit_keys, severity: nil)
434
+ keys = limit_keys.flatten
435
+ return nil if keys.empty?
436
+ sev = severity || highest_severity_for(plan_owner, *keys)
437
+ return nil if sev == :ok
438
+
439
+ affected = keys.select { |k| severity_for(plan_owner, k) == sev }
440
+ human_keys = affected.map { |k| k.to_s.humanize.downcase }
441
+ keys_list = if human_keys.respond_to?(:to_sentence)
442
+ human_keys.to_sentence
443
+ else
444
+ # Simple fallback: "a, b and c"
445
+ if human_keys.length <= 2
446
+ human_keys.join(" and ")
447
+ else
448
+ human_keys[0..-2].join(", ") + " and " + human_keys[-1]
449
+ end
450
+ end
451
+ noun = affected.size == 1 ? "plan limit" : "plan limits"
452
+
453
+ case sev
454
+ when :blocked
455
+ "Your #{noun} for #{keys_list} #{affected.size == 1 ? "has" : "have"} been exceeded. Please upgrade to continue."
456
+ when :grace
457
+ # If any grace ends_at is present, show the earliest
458
+ grace_end = keys.map { |k| GraceManager.grace_ends_at(plan_owner, k) }.compact.min
459
+ suffix = grace_end ? ", grace active until #{grace_end}" : ""
460
+ "You are over your #{noun} for #{keys_list}#{suffix}. Please upgrade to avoid service disruption."
461
+ when :at_limit
462
+ "You have reached your #{noun} for #{keys_list}."
463
+ else # :warning
464
+ "You are approaching your #{noun} for #{keys_list}."
465
+ end
466
+ end
467
+
468
+ # Combine human messages for a set of limits into one string
469
+ def combine_messages_for(plan_owner, *limit_keys)
470
+ keys = limit_keys.flatten
471
+ parts = keys.map do |key|
472
+ result = ControllerGuards.require_plan_limit!(key, plan_owner: plan_owner, by: 0)
473
+ next nil if result.ok?
474
+ "#{key.to_s.humanize}: #{result.message}"
475
+ end.compact
476
+ return nil if parts.empty?
477
+ parts.join(" · ")
478
+ end
479
+
480
+ # Convenience: severity for a single limit key
481
+ # Returns :ok | :warning | :grace | :blocked
482
+ def severity_for(plan_owner, limit_key)
483
+ highest_severity_for(plan_owner, limit_key)
484
+ end
485
+
486
+ # Convenience: message for a single limit key, or nil if OK
487
+ def message_for(plan_owner, limit_key)
488
+ st = limit_status(limit_key, plan_owner: plan_owner)
489
+ return nil unless st[:configured]
490
+
491
+ severity = severity_for(plan_owner, limit_key)
492
+ return nil if severity == :ok
493
+
494
+ cfg = configuration
495
+ current_usage = st[:current_usage]
496
+ limit_amount = st[:limit_amount]
497
+ grace_ends_at = st[:grace_ends_at]
498
+
499
+ if cfg.message_builder
500
+ context = case severity
501
+ when :blocked then :over_limit
502
+ when :grace then :grace
503
+ when :at_limit then :at_limit
504
+ else :warning
505
+ end
506
+ begin
507
+ custom = cfg.message_builder.call(context: context, limit_key: limit_key, current_usage: current_usage, limit_amount: limit_amount, grace_ends_at: grace_ends_at)
508
+ return custom if custom
509
+ rescue StandardError
510
+ # fall through to defaults
511
+ end
512
+ end
513
+
514
+ noun = begin
515
+ PricingPlans.noun_for(limit_key)
516
+ rescue StandardError
517
+ "limit"
518
+ end
519
+
520
+ case severity
521
+ when :blocked
522
+ if limit_amount.is_a?(Numeric)
523
+ "You've gone over your #{noun} for #{limit_key.to_s.humanize.downcase} (#{current_usage}/#{limit_amount}). Please upgrade your plan."
524
+ else
525
+ "You've gone over your #{noun} for #{limit_key.to_s.humanize.downcase}. Please upgrade your plan."
526
+ end
527
+ when :grace
528
+ deadline = grace_ends_at ? ", and your grace period ends #{grace_ends_at.strftime('%B %d at %I:%M%p')}" : ""
529
+ if limit_amount.is_a?(Numeric)
530
+ "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."
531
+ else
532
+ "Heads up! You’re currently over your #{noun} for #{limit_key.to_s.humanize.downcase}#{deadline}. Please upgrade soon to avoid any interruptions."
533
+ end
534
+ when :at_limit
535
+ if limit_amount.is_a?(Numeric)
536
+ "You’ve reached your #{noun} for #{limit_key.to_s.humanize.downcase} (#{current_usage}/#{limit_amount}). Upgrade your plan to unlock more."
537
+ else
538
+ "You’re at the maximum allowed for #{limit_key.to_s.humanize.downcase}. Want more? Consider upgrading your plan."
539
+ end
540
+ else # :warning
541
+ if limit_amount.is_a?(Numeric)
542
+ "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."
543
+ else
544
+ "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."
545
+ end
546
+ end
547
+ end
548
+
549
+ # Compute how much over the limit the plan_owner is for a key (0 if within)
550
+ def overage_for(plan_owner, limit_key)
551
+ st = limit_status(limit_key, plan_owner: plan_owner)
552
+ return 0 unless st[:configured]
553
+ allowed = st[:limit_amount]
554
+ current = st[:current_usage].to_i
555
+ return 0 unless allowed.is_a?(Numeric)
556
+ [current - allowed.to_i, 0].max
557
+ end
558
+
559
+ # Boolean: any attention required (warning/grace/blocked) for provided keys
560
+ def attention_required?(plan_owner, *limit_keys)
561
+ highest_severity_for(plan_owner, *limit_keys) != :ok
562
+ end
563
+
564
+ # Boolean: approaching a limit. If `at:` given, uses that numeric threshold (0..1);
565
+ # otherwise uses the highest configured warn_at threshold for the limit.
566
+ def approaching_limit?(plan_owner, limit_key, at: nil)
567
+ st = limit_status(limit_key, plan_owner: plan_owner)
568
+ return false unless st[:configured]
569
+ percent = st[:percent_used].to_f
570
+ threshold = if at
571
+ (at.to_f * 100.0)
572
+ else
573
+ thresholds = LimitChecker.warning_thresholds(plan_owner, limit_key)
574
+ thresholds.max.to_f * 100.0
575
+ end
576
+ return false if threshold <= 0.0
577
+ percent >= threshold
578
+ end
579
+
580
+ # Recommend CTA data (pure data, no UI): { text:, url: }
581
+ # For limit banners, prefer global upgrade defaults to avoid confusing “Current Plan” CTAs
582
+ def cta_for_upgrade(plan_owner)
583
+ cfg = configuration
584
+ url = cfg.default_cta_url
585
+ url ||= (cfg.redirect_on_blocked_limit.is_a?(String) ? cfg.redirect_on_blocked_limit : nil)
586
+ text = cfg.default_cta_text.presence || "View Plans"
587
+ { text: text, url: url }
588
+ end
589
+
590
+ # Recommend CTA data used by other contexts (pricing plan cards etc.)
591
+ def cta_for(plan_owner)
592
+ plan = PlanResolver.effective_plan_for(plan_owner)
593
+ cfg = configuration
594
+ url = plan&.cta_url(plan_owner: plan_owner) || cfg.default_cta_url
595
+ if url.nil? && cfg.redirect_on_blocked_limit.is_a?(String)
596
+ url = cfg.redirect_on_blocked_limit
597
+ end
598
+ text = plan&.cta_text || cfg.default_cta_text
599
+ { text: text, url: url }
600
+ end
601
+
602
+ # Pure-data alert view model for a single limit key. No HTML.
603
+ # Returns keys: :visible? (boolean), :severity, :title, :message, :overage, :cta_text, :cta_url
604
+ def alert_for(plan_owner, limit_key)
605
+ sev = severity_for(plan_owner, limit_key)
606
+ return { visible?: false, severity: :ok } if sev == :ok
607
+
608
+ msg = message_for(plan_owner, limit_key)
609
+ over = overage_for(plan_owner, limit_key)
610
+ cta = cta_for_upgrade(plan_owner)
611
+ titles = {
612
+ warning: "Approaching Limit",
613
+ at_limit: "You've reached your #{limit_key.to_s.humanize.downcase} limit",
614
+ grace: "Limit for #{limit_key.to_s.humanize.downcase} exceeded (in grace period)",
615
+ blocked: "Cannot create more #{limit_key.to_s.humanize.downcase}"
616
+ }
617
+ {
618
+ visible?: true,
619
+ severity: sev,
620
+ title: titles[sev] || sev.to_s.humanize,
621
+ message: msg,
622
+ overage: over,
623
+ cta_text: cta[:text],
624
+ cta_url: cta[:url]
625
+ }
626
+ end
627
+
628
+ # Global highlighted/popular plan sugar (UI ergonomics)
629
+ def highlighted_plan
630
+ Registry.highlighted_plan
631
+ end
632
+
633
+ def highlighted_plan_key
634
+ highlighted_plan&.key
635
+ end
636
+
637
+ def popular_plan
638
+ highlighted_plan
639
+ end
640
+
641
+ def popular_plan_key
642
+ highlighted_plan_key
643
+ end
644
+ end
645
+ end