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,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
|