Class: PricingPlans::Plan

Inherits:
Object
  • Object
show all
Defined in:
lib/pricing_plans/plan.rb

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(key) ⇒ Plan

Returns a new instance of Plan.



11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# File 'lib/pricing_plans/plan.rb', line 11

def initialize(key)
  @key = key
  @name = nil
  @description = nil
  @bullets = []
  @price = nil
  @price_string = nil
  @stripe_price = nil
  @features = Set.new
  @limits = {}
  @credits_included = nil
  @meta = {}
  @cta_text = nil
  @cta_url = nil
  @default = false
  @highlighted = false
  @hidden = false
end

Instance Attribute Details

#featuresObject (readonly)

Returns the value of attribute features.



9
10
11
# File 'lib/pricing_plans/plan.rb', line 9

def features
  @features
end

#keyObject (readonly)

Returns the value of attribute key.



9
10
11
# File 'lib/pricing_plans/plan.rb', line 9

def key
  @key
end

Instance Method Details

#allow(*feature_keys) ⇒ Object



188
189
190
# File 'lib/pricing_plans/plan.rb', line 188

def allow(*feature_keys)
  allows(*feature_keys)
end

#allows(*feature_keys) ⇒ Object

Feature methods



182
183
184
185
186
# File 'lib/pricing_plans/plan.rb', line 182

def allows(*feature_keys)
  feature_keys.flatten.each do |key|
    @features.add(key.to_sym)
  end
end

#allows_feature?(feature_key) ⇒ Boolean

Returns:

  • (Boolean)


202
203
204
# File 'lib/pricing_plans/plan.rb', line 202

def allows_feature?(feature_key)
  @features.include?(feature_key.to_sym)
end

#bullets(*values) ⇒ Object



59
60
61
62
63
64
65
# File 'lib/pricing_plans/plan.rb', line 59

def bullets(*values)
  if values.empty?
    @bullets
  else
    set_bullets(*values)
  end
end

#credits_included(value = :__get__) ⇒ Object



250
251
252
253
254
255
256
# File 'lib/pricing_plans/plan.rb', line 250

def credits_included(value = :__get__)
  if value == :__get__
    @credits_included
  else
    @credits_included = value.to_i
  end
end

#cta_text(value = nil) ⇒ Object



150
151
152
153
154
155
156
# File 'lib/pricing_plans/plan.rb', line 150

def cta_text(value = nil)
  if value.nil?
    @cta_text || PricingPlans.configuration.default_cta_text || default_cta_text_derived
  else
    set_cta_text(value)
  end
end

#cta_url(value = :__no_arg__, plan_owner: nil) ⇒ Object

Unified ergonomic API:

  • Setter/getter: cta_url, cta_url("/checkout")
  • Resolver: cta_url(plan_owner: org)


165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
# File 'lib/pricing_plans/plan.rb', line 165

def cta_url(value = :__no_arg__, plan_owner: nil)
  unless value == :__no_arg__
    set_cta_url(value)
    return @cta_url
  end

  return @cta_url if @cta_url
  default = PricingPlans.configuration.default_cta_url
  return default if default
  # New default: if host app defines subscribe_path, prefer that
  if defined?(Rails) && Rails.respond_to?(:application) && Rails.application && Rails.application.routes.url_helpers.respond_to?(:subscribe_path)
    return Rails.application.routes.url_helpers.subscribe_path(plan: key, interval: :month)
  end
  nil
end

#currency_symbolObject



447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
# File 'lib/pricing_plans/plan.rb', line 447

def currency_symbol
  # A locally declared numeric price is rendered in the configured currency
  # (see #price_components), so don't ask Stripe when we have one.
  if stripe_price && price.nil?
    # Try to derive from Stripe API/cache; fall back to default
    begin
      pr = fetch_stripe_price_record(preferred_price_id(:month) || preferred_price_id(:year))
      if pr
        return currency_symbol_from(pr)
      end
    rescue StandardError
      # Stripe unreachable, rate-limited or unconfigured: never take down a pricing page
    end
  end
  PricingPlans.configuration.default_currency_symbol
end

#current_for?(current_plan) ⇒ Boolean

Plan comparison helpers for CTA ergonomics

Returns:

  • (Boolean)


465
466
467
468
# File 'lib/pricing_plans/plan.rb', line 465

def current_for?(current_plan)
  return false unless current_plan
  current_plan.key.to_sym == key.to_sym
end

#default!(value = true) ⇒ Object

Plan selection sugar



259
260
261
# File 'lib/pricing_plans/plan.rb', line 259

def default!(value = true)
  @default = !!value
end

#default?Boolean

Returns:

  • (Boolean)


263
264
265
# File 'lib/pricing_plans/plan.rb', line 263

def default?
  !!@default
end

#description(value = nil) ⇒ Object



47
48
49
50
51
52
53
# File 'lib/pricing_plans/plan.rb', line 47

def description(value = nil)
  if value.nil?
    @description
  else
    set_description(value)
  end
end

#disallow(*feature_keys) ⇒ Object



198
199
200
# File 'lib/pricing_plans/plan.rb', line 198

def disallow(*feature_keys)
  disallows(*feature_keys)
end

#disallows(*feature_keys) ⇒ Object



192
193
194
195
196
# File 'lib/pricing_plans/plan.rb', line 192

def disallows(*feature_keys)
  feature_keys.flatten.each do |key|
    @features.delete(key.to_sym)
  end
end

#downgrade_blocked_reason(from: nil, plan_owner: nil) ⇒ Object



480
481
482
483
484
# File 'lib/pricing_plans/plan.rb', line 480

def downgrade_blocked_reason(from: nil, plan_owner: nil)
  return nil unless from
  allowed, reason = PricingPlans.configuration.downgrade_policy.call(from: from, to: self, plan_owner: plan_owner)
  allowed ? nil : (reason || "Downgrade not allowed")
end

#downgrade_from?(current_plan) ⇒ Boolean

Returns:

  • (Boolean)


475
476
477
478
# File 'lib/pricing_plans/plan.rb', line 475

def downgrade_from?(current_plan)
  return false unless current_plan
  comparable_price_cents(self) < comparable_price_cents(current_plan)
end

#free?Boolean

Ergonomic predicate for UI/logic (free means explicit 0 price or explicit "Free" label)

Returns:

  • (Boolean)


92
93
94
95
96
97
# File 'lib/pricing_plans/plan.rb', line 92

def free?
  return false if @stripe_price
  return true if @price.respond_to?(:to_i) && @price.to_i.zero?
  return true if @price_string && @price_string.to_s.strip.casecmp("Free").zero?
  false
end

#has_interval_prices?Boolean

Returns:

  • (Boolean)


413
414
415
416
417
# File 'lib/pricing_plans/plan.rb', line 413

def has_interval_prices?
  sp = stripe_price
  return true if sp.is_a?(Hash) && (sp[:month] || sp[:year])
  return !price.nil? || !price_string.nil?
end

#has_numeric_price?Boolean

Returns:

  • (Boolean)


419
420
421
# File 'lib/pricing_plans/plan.rb', line 419

def has_numeric_price?
  !!price || !!stripe_price
end

#hidden!(value = true) ⇒ Object



282
283
284
# File 'lib/pricing_plans/plan.rb', line 282

def hidden!(value = true)
  @hidden = !!value
end

#hidden?Boolean

Returns:

  • (Boolean)


286
287
288
# File 'lib/pricing_plans/plan.rb', line 286

def hidden?
  !!@hidden
end

#highlighted!(value = true) ⇒ Object



267
268
269
# File 'lib/pricing_plans/plan.rb', line 267

def highlighted!(value = true)
  @highlighted = !!value
end

#highlighted?Boolean

Returns:

  • (Boolean)


271
272
273
274
275
276
277
278
279
280
# File 'lib/pricing_plans/plan.rb', line 271

def highlighted?
  return true if @highlighted
  # Treat configuration.highlighted_plan as highlighted without consulting Registry to avoid recursion
  begin
    cfg = PricingPlans.configuration
    return true if cfg && cfg.highlighted_plan && cfg.highlighted_plan.to_sym == @key
  rescue StandardError
  end
  false
end

#includes_credits(amount) ⇒ Object

Credits display methods (cosmetic, for pricing UI) Single-currency credits. We do not tie credits to operations here.



246
247
248
# File 'lib/pricing_plans/plan.rb', line 246

def includes_credits(amount)
  @credits_included = amount.to_i
end

#limit(key, **options) ⇒ Object



230
231
232
# File 'lib/pricing_plans/plan.rb', line 230

def limit(key, **options)
  set_limit(key, **options)
end

#limit_for(key) ⇒ Object



240
241
242
# File 'lib/pricing_plans/plan.rb', line 240

def limit_for(key)
  @limits[key.to_sym]
end

#limits(key = nil, **options) ⇒ Object



222
223
224
225
226
227
228
# File 'lib/pricing_plans/plan.rb', line 222

def limits(key=nil, **options)
  if key.nil?
    @limits
  else
    set_limit(key, **options)
  end
end

#meta(values = nil) ⇒ Object Also known as: metadata



134
135
136
137
138
139
140
# File 'lib/pricing_plans/plan.rb', line 134

def meta(values = nil)
  if values.nil?
    @meta
  else
    set_meta(values)
  end
end

#monthly_price_centsObject

Stripe convenience accessors (nil when interval not present)



429
430
431
432
# File 'lib/pricing_plans/plan.rb', line 429

def monthly_price_cents
  pc = monthly_price_components
  pc.present? ? pc.amount_cents : nil
end

#monthly_price_componentsObject



405
406
407
# File 'lib/pricing_plans/plan.rb', line 405

def monthly_price_components
  price_components(interval: :month)
end

#monthly_price_idObject



439
440
441
# File 'lib/pricing_plans/plan.rb', line 439

def monthly_price_id
  stripe_price_id_for(:month)
end

#name(value = nil) ⇒ Object



35
36
37
38
39
40
41
# File 'lib/pricing_plans/plan.rb', line 35

def name(value = nil)
  if value.nil?
    @name || @key.to_s.titleize
  else
    set_name(value)
  end
end

#popular?Boolean

Syntactic sugar for popular/highlighted

Returns:

  • (Boolean)


291
292
293
# File 'lib/pricing_plans/plan.rb', line 291

def popular?
  highlighted?
end

#price(value = nil) ⇒ Object



71
72
73
74
75
76
77
# File 'lib/pricing_plans/plan.rb', line 71

def price(value = nil)
  if value.nil?
    @price
  else
    set_price(value)
  end
end

#price_centsObject

Rails-y ergonomics for UI: expose integer cents as optional helper



80
81
82
83
84
85
86
87
88
89
# File 'lib/pricing_plans/plan.rb', line 80

def price_cents
  return nil unless @price
  (
    if @price.respond_to?(:to_f)
      (@price.to_f * 100).round
    else
      nil
    end
  )
end

#price_components(interval: :month) ⇒ Object

Compute semantic price parts for the given interval (:month or :year). Falls back to price_string when no numeric price exists.



343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
# File 'lib/pricing_plans/plan.rb', line 343

def price_components(interval: :month)
  # 1) Allow app override
  if (resolver = PricingPlans.configuration.price_components_resolver)
    begin
      resolved = resolver.call(self, interval)
      return resolved if resolved
    rescue StandardError
    end
  end

  # 2) String-only prices
  if price_string
    return PricingPlans::PriceComponents.new(
      present?: false,
      currency: nil,
      amount: nil,
      amount_cents: nil,
      interval: interval,
      label: price_string,
      monthly_equivalent_cents: nil
    )
  end

  # 3) Explicit numeric price (single interval, assume monthly semantics)
  if price
    cents = price_cents
    cur = PricingPlans.configuration.default_currency_symbol
    label = if interval == :month
      "#{cur}#{price}/mo"
    else
      # Treat yearly as 12x when only a single numeric price is declared
      "#{cur}#{(price.to_f * 12).round}/yr"
    end
    return PricingPlans::PriceComponents.new(
      present?: true,
      currency: cur,
      amount: (interval == :month ? price.to_i : (price.to_f * 12).round).to_s,
      amount_cents: (interval == :month ? cents : (cents.to_i * 12)),
      interval: interval,
      label: label,
      monthly_equivalent_cents: cents
    )
  end

  # 4) Stripe price(s)
  if stripe_price
    comp = stripe_price_components(interval)
    return comp if comp
  end

  # 5) No price info at all → Contact
  PricingPlans::PriceComponents.new(
    present?: false,
    currency: nil,
    amount: nil,
    amount_cents: nil,
    interval: interval,
    label: "Contact",
    monthly_equivalent_cents: nil
  )
end

#price_labelObject

Human label to display price in UIs. Prefers explicit string, then numeric, else contact.



303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
# File 'lib/pricing_plans/plan.rb', line 303

def price_label
  # Auto-fetch from processor (Stripe) if enabled and plan has stripe_price.
  # A locally declared numeric price wins: it is the source of truth for
  # display, and honoring it keeps rendering off the network entirely.
  cfg = PricingPlans.configuration
  if cfg&.auto_price_labels_from_processor && stripe_price && price.nil?
    begin
      if defined?(::Stripe)
        price_id = stripe_price.is_a?(Hash) ? (stripe_price[:id] || stripe_price[:month] || stripe_price[:year]) : stripe_price
        if price_id
          pr = ::Stripe::Price.retrieve(price_id)
          amount = pr.unit_amount.to_f / 100.0
          interval = pr.recurring&.interval
          suffix = interval ? "/#{interval[0,3]}" : ""
          return "$#{amount}#{suffix}"
        end
      end
    rescue StandardError
      # fallthrough to local derivation
    end
  end
  # Allow host app override via resolver
  if cfg&.price_label_resolver
    begin
      built = cfg.price_label_resolver.call(self)
      return built if built
    rescue StandardError
    end
  end
  return "Free" if price && price.to_i.zero?
  return price_string if price_string
  return "$#{price}/mo" if price
  return "Contact" if stripe_price || price.nil?
  nil
end

#price_label_for(interval) ⇒ Object



423
424
425
426
# File 'lib/pricing_plans/plan.rb', line 423

def price_label_for(interval)
  pc = price_components(interval: interval)
  pc.label
end

#price_string(value = nil) ⇒ Object



103
104
105
106
107
108
109
# File 'lib/pricing_plans/plan.rb', line 103

def price_string(value = nil)
  if value.nil?
    @price_string
  else
    set_price_string(value)
  end
end

#purchasable?Boolean

Convenience booleans used by views/hosts (keep single definition above)

Returns:

  • (Boolean)


298
299
300
# File 'lib/pricing_plans/plan.rb', line 298

def purchasable?
  !!@stripe_price || (!free? && !!@price)
end

#set_bullets(*values) ⇒ Object



55
56
57
# File 'lib/pricing_plans/plan.rb', line 55

def set_bullets(*values)
  @bullets = values.flatten.map(&:to_s)
end

#set_cta_text(value) ⇒ Object

CTA helpers for pricing UI



146
147
148
# File 'lib/pricing_plans/plan.rb', line 146

def set_cta_text(value)
  @cta_text = value&.to_s
end

#set_cta_url(value) ⇒ Object



158
159
160
# File 'lib/pricing_plans/plan.rb', line 158

def set_cta_url(value)
  @cta_url = value&.to_s
end

#set_description(value) ⇒ Object



43
44
45
# File 'lib/pricing_plans/plan.rb', line 43

def set_description(value)
  @description = value.to_s
end

#set_limit(key, **options) ⇒ Object

Limit methods



207
208
209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/pricing_plans/plan.rb', line 207

def set_limit(key, **options)
  limit_key = key.to_sym
  @limits[limit_key] = {
    key: limit_key,
    to: options[:to],
    per: options[:per],
    after_limit: options.fetch(:after_limit, :block_usage),
    grace: options.fetch(:grace, 7.days),
    warn_at: options.fetch(:warn_at, [0.6, 0.8, 0.95]),
    count_scope: options[:count_scope]
  }

  validate_limit_options!(@limits[limit_key])
end

#set_meta(values) ⇒ Object Also known as: set_metadata



130
131
132
# File 'lib/pricing_plans/plan.rb', line 130

def set_meta(values)
  @meta.merge!(values)
end

#set_name(value) ⇒ Object

DSL methods for plan configuration



31
32
33
# File 'lib/pricing_plans/plan.rb', line 31

def set_name(value)
  @name = value.to_s
end

#set_price(value) ⇒ Object



67
68
69
# File 'lib/pricing_plans/plan.rb', line 67

def set_price(value)
  @price = value
end

#set_price_string(value) ⇒ Object



99
100
101
# File 'lib/pricing_plans/plan.rb', line 99

def set_price_string(value)
  @price_string = value.to_s
end

#set_stripe_price(value) ⇒ Object



111
112
113
114
115
116
117
118
119
120
# File 'lib/pricing_plans/plan.rb', line 111

def set_stripe_price(value)
  case value
  when String
    @stripe_price = { id: value }
  when Hash
    @stripe_price = value
  else
    raise ConfigurationError, "stripe_price must be a string or hash"
  end
end

#stripe_price(value = nil) ⇒ Object



122
123
124
125
126
127
128
# File 'lib/pricing_plans/plan.rb', line 122

def stripe_price(value = nil)
  if value.nil?
    @stripe_price
  else
    set_stripe_price(value)
  end
end

#to_view_modelObject

Pure-data view model for JS/Hotwire



487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
# File 'lib/pricing_plans/plan.rb', line 487

def to_view_model
  {
    id: key.to_s,
    key: key.to_s,
    name: name,
    description: description,
    features: bullets, # alias in this gem
    metadata: .dup,
    highlighted: highlighted?,
    default: default?,
    free: free?,
    currency: currency_symbol,
    monthly_price_cents: monthly_price_cents,
    yearly_price_cents: yearly_price_cents,
    monthly_price_id: monthly_price_id,
    yearly_price_id: yearly_price_id,
    price_label: price_label,
    price_string: price_string,
    limits: limits.transform_values { |v| v.dup }
  }
end

#unlimited(*keys) ⇒ Object



234
235
236
237
238
# File 'lib/pricing_plans/plan.rb', line 234

def unlimited(*keys)
  keys.flatten.each do |key|
    set_limit(key.to_sym, to: :unlimited)
  end
end

#upgrade_from?(current_plan) ⇒ Boolean

Returns:

  • (Boolean)


470
471
472
473
# File 'lib/pricing_plans/plan.rb', line 470

def upgrade_from?(current_plan)
  return false unless current_plan
  comparable_price_cents(self) > comparable_price_cents(current_plan)
end

#validate!Object



509
510
511
512
# File 'lib/pricing_plans/plan.rb', line 509

def validate!
  validate_limits!
  validate_pricing!
end

#yearly_price_centsObject



434
435
436
437
# File 'lib/pricing_plans/plan.rb', line 434

def yearly_price_cents
  pc = yearly_price_components
  pc.present? ? pc.amount_cents : nil
end

#yearly_price_componentsObject



409
410
411
# File 'lib/pricing_plans/plan.rb', line 409

def yearly_price_components
  price_components(interval: :year)
end

#yearly_price_idObject



443
444
445
# File 'lib/pricing_plans/plan.rb', line 443

def yearly_price_id
  stripe_price_id_for(:year)
end