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
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module PricingPlans
|
|
7
|
+
module Generators
|
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
desc "Install pricing_plans migrations and initializer"
|
|
13
|
+
|
|
14
|
+
def self.next_migration_number(dir)
|
|
15
|
+
ActiveRecord::Generators::Base.next_migration_number(dir)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def create_migration_file
|
|
19
|
+
migration_template "create_pricing_plans_tables.rb.erb", File.join(db_migrate_path, "create_pricing_plans_tables.rb"), migration_version: migration_version
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def create_initializer
|
|
23
|
+
template "initializer.rb", "config/initializers/pricing_plans.rb"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def display_post_install_message
|
|
27
|
+
say "\n✅ pricing_plans has been installed.", :green
|
|
28
|
+
say "\nNext steps:"
|
|
29
|
+
say " 1. Run 'rails db:migrate' to create the necessary tables."
|
|
30
|
+
say " 2. Review and customize your plans in 'config/initializers/pricing_plans.rb'."
|
|
31
|
+
say " 3. Add the model mixin (PricingPlans::PlanOwner) and attribute limits to your plan owner model (e.g., User, Organization)."
|
|
32
|
+
say " 4. Use the controller guards and helper methods to gate access to features based on the active plan. Read the README and the docs for information on all available methods."
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def migration_version
|
|
38
|
+
"[#{ActiveRecord::VERSION::STRING.to_f}]"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreatePricingPlansTables < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def change
|
|
5
|
+
primary_key_type, foreign_key_type = primary_and_foreign_key_types
|
|
6
|
+
|
|
7
|
+
create_table :pricing_plans_enforcement_states, id: primary_key_type do |t|
|
|
8
|
+
t.references :plan_owner, polymorphic: true, null: false, type: foreign_key_type
|
|
9
|
+
t.string :limit_key, null: false
|
|
10
|
+
t.datetime :exceeded_at
|
|
11
|
+
t.datetime :blocked_at
|
|
12
|
+
t.decimal :last_warning_threshold, precision: 3, scale: 2
|
|
13
|
+
t.datetime :last_warning_at
|
|
14
|
+
t.send(json_column_type, :data, default: {})
|
|
15
|
+
|
|
16
|
+
t.timestamps
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
add_index :pricing_plans_enforcement_states,
|
|
20
|
+
[:plan_owner_type, :plan_owner_id, :limit_key],
|
|
21
|
+
unique: true,
|
|
22
|
+
name: "idx_pricing_plans_enforcement_unique"
|
|
23
|
+
|
|
24
|
+
add_index :pricing_plans_enforcement_states,
|
|
25
|
+
[:plan_owner_type, :plan_owner_id],
|
|
26
|
+
name: "idx_pricing_plans_enforcement_plan_owner"
|
|
27
|
+
|
|
28
|
+
add_index :pricing_plans_enforcement_states,
|
|
29
|
+
:exceeded_at,
|
|
30
|
+
where: "exceeded_at IS NOT NULL",
|
|
31
|
+
name: "idx_pricing_plans_enforcement_exceeded"
|
|
32
|
+
|
|
33
|
+
create_table :pricing_plans_usages, id: primary_key_type do |t|
|
|
34
|
+
t.references :plan_owner, polymorphic: true, null: false, type: foreign_key_type
|
|
35
|
+
t.string :limit_key, null: false
|
|
36
|
+
t.datetime :period_start, null: false
|
|
37
|
+
t.datetime :period_end, null: false
|
|
38
|
+
t.bigint :used, default: 0, null: false
|
|
39
|
+
t.datetime :last_used_at
|
|
40
|
+
|
|
41
|
+
t.timestamps
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
add_index :pricing_plans_usages,
|
|
45
|
+
[:plan_owner_type, :plan_owner_id, :limit_key, :period_start],
|
|
46
|
+
unique: true,
|
|
47
|
+
name: "idx_pricing_plans_usages_unique"
|
|
48
|
+
|
|
49
|
+
add_index :pricing_plans_usages,
|
|
50
|
+
[:plan_owner_type, :plan_owner_id],
|
|
51
|
+
name: "idx_pricing_plans_usages_plan_owner"
|
|
52
|
+
|
|
53
|
+
add_index :pricing_plans_usages,
|
|
54
|
+
[:period_start, :period_end],
|
|
55
|
+
name: "idx_pricing_plans_usages_period"
|
|
56
|
+
|
|
57
|
+
create_table :pricing_plans_assignments, id: primary_key_type do |t|
|
|
58
|
+
t.references :plan_owner, polymorphic: true, null: false, type: foreign_key_type
|
|
59
|
+
t.string :plan_key, null: false
|
|
60
|
+
t.string :source, null: false, default: "manual"
|
|
61
|
+
|
|
62
|
+
t.timestamps
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
add_index :pricing_plans_assignments,
|
|
66
|
+
[:plan_owner_type, :plan_owner_id],
|
|
67
|
+
unique: true,
|
|
68
|
+
name: "idx_pricing_plans_assignments_unique"
|
|
69
|
+
|
|
70
|
+
add_index :pricing_plans_assignments,
|
|
71
|
+
:plan_key,
|
|
72
|
+
name: "idx_pricing_plans_assignments_plan"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def primary_and_foreign_key_types
|
|
78
|
+
config = Rails.configuration.generators
|
|
79
|
+
setting = config.options[config.orm][:primary_key_type]
|
|
80
|
+
primary_key_type = setting || :primary_key
|
|
81
|
+
foreign_key_type = setting || :bigint
|
|
82
|
+
[primary_key_type, foreign_key_type]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def json_column_type
|
|
86
|
+
return :jsonb if connection.adapter_name.downcase.include?("postgresql")
|
|
87
|
+
:json
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
PricingPlans.configure do |config|
|
|
4
|
+
# Example plans
|
|
5
|
+
plan :free do
|
|
6
|
+
price 0
|
|
7
|
+
|
|
8
|
+
description "Perfect for getting started"
|
|
9
|
+
bullets "Basic features", "Community support"
|
|
10
|
+
|
|
11
|
+
limits :projects, to: 3, after_limit: :block_usage
|
|
12
|
+
# Example scoped persistent cap (active-only rows)
|
|
13
|
+
# limits :projects, to: 3, count_scope: { status: "active" }
|
|
14
|
+
default!
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
plan :pro do
|
|
18
|
+
description "For growing teams and businesses"
|
|
19
|
+
bullets "Advanced features", "Priority support", "API access"
|
|
20
|
+
|
|
21
|
+
allows :api_access, :premium_features
|
|
22
|
+
limits :projects, to: 25, after_limit: :grace_then_block, grace: 7.days
|
|
23
|
+
|
|
24
|
+
highlighted!
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
plan :enterprise do
|
|
28
|
+
price_string "Contact us"
|
|
29
|
+
|
|
30
|
+
description "Get in touch and we'll fit your needs."
|
|
31
|
+
bullets "Custom limits", "Dedicated SLAs", "Dedicated support"
|
|
32
|
+
cta_text "Contact sales"
|
|
33
|
+
cta_url "mailto:sales@example.com"
|
|
34
|
+
|
|
35
|
+
unlimited :projects
|
|
36
|
+
allows :api_access, :premium_features
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Optional settings
|
|
41
|
+
|
|
42
|
+
# Optional: global controller plan owner resolver (per-controller still wins)
|
|
43
|
+
# Either a symbol helper name or a block evaluated in the controller
|
|
44
|
+
# config.controller_plan_owner :current_organization
|
|
45
|
+
# or
|
|
46
|
+
# config.controller_plan_owner { current_account }
|
|
47
|
+
|
|
48
|
+
# Period cycle for per-period limits
|
|
49
|
+
# :billing_cycle, :calendar_month, :calendar_week, :calendar_day
|
|
50
|
+
# Global default period for per-period limits (can be overridden per limit via `per:`)
|
|
51
|
+
# config.period_cycle = :billing_cycle
|
|
52
|
+
|
|
53
|
+
# Optional defaults for pricing UI calls‑to‑action
|
|
54
|
+
# config.default_cta_text = "Choose plan"
|
|
55
|
+
# config.default_cta_url = nil # e.g., "/pricing" or your billing path
|
|
56
|
+
#
|
|
57
|
+
# By convention, if your app defines `subscribe_path(plan:, interval:)`,
|
|
58
|
+
# `plan.cta_url` will automatically point to it (default interval :month).
|
|
59
|
+
# See README: Controller‑first Stripe Checkout wiring.
|
|
60
|
+
|
|
61
|
+
# Controller ergonomics — global default redirect when a limit blocks
|
|
62
|
+
# Accepts:
|
|
63
|
+
# - Symbol: a controller helper method, e.g. :pricing_path
|
|
64
|
+
# - String: a path or URL, e.g. "/pricing"
|
|
65
|
+
# - Proc: instance-exec'd in the controller with the Result: ->(result) { pricing_path }
|
|
66
|
+
# Examples:
|
|
67
|
+
# config.redirect_on_blocked_limit = :pricing_path
|
|
68
|
+
# config.redirect_on_blocked_limit = "/pricing"
|
|
69
|
+
# config.redirect_on_blocked_limit = ->(result) { pricing_path }
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
#`config.message_builder` lets apps override human copy for `:over_limit`, `:grace`, `:feature_denied`, and overage report; used broadly across guards/UX.
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# Optional event callbacks -- enqueue jobs here to send notifications or emails when certain events happen
|
|
76
|
+
# config.on_warning(:products) { |org, threshold| PlanMailer.quota_warning(org, :products, threshold).deliver_later }
|
|
77
|
+
# config.on_grace_start(:products) { |org, ends_at| PlanMailer.grace_started(org, :products, ends_at).deliver_later }
|
|
78
|
+
# config.on_block(:products) { |org| PlanMailer.blocked(org, :products).deliver_later }
|
|
79
|
+
|
|
80
|
+
# --- Pricing semantics (UI-agnostic) ---
|
|
81
|
+
# Currency symbol to use when Stripe is absent
|
|
82
|
+
# config.default_currency_symbol = "$"
|
|
83
|
+
|
|
84
|
+
# Cache for Stripe Price lookups (defaults to Rails.cache when available)
|
|
85
|
+
# config.price_cache = Rails.cache
|
|
86
|
+
# TTL for Stripe price cache (seconds)
|
|
87
|
+
# config.price_cache_ttl = 10.minutes
|
|
88
|
+
|
|
89
|
+
# Build semantic price parts yourself (optional). Return a PricingPlans::PriceComponents or nil to fallback
|
|
90
|
+
# config.price_components_resolver = ->(plan, interval) { nil }
|
|
91
|
+
|
|
92
|
+
# Free copy helper (used by some view-models)
|
|
93
|
+
# config.free_price_caption = "Forever free"
|
|
94
|
+
|
|
95
|
+
# Default UI interval for toggles
|
|
96
|
+
# config.interval_default_for_ui = :month # or :year
|
|
97
|
+
|
|
98
|
+
# Downgrade policy hook used by CTA ergonomics helpers
|
|
99
|
+
# config.downgrade_policy = ->(from:, to:, plan_owner:) { [true, nil] }
|
|
100
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PricingPlans
|
|
4
|
+
# Stores has_many limited_by_pricing_plans declarations that could not be
|
|
5
|
+
# resolved at declaration time (e.g., child class not loaded yet). Flushed
|
|
6
|
+
# after registry configuration or on engine to_prepare.
|
|
7
|
+
class AssociationLimitRegistry
|
|
8
|
+
class << self
|
|
9
|
+
def pending
|
|
10
|
+
@pending ||= []
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def register(plan_owner_class:, association_name:, options:)
|
|
14
|
+
pending << { plan_owner_class: plan_owner_class, association_name: association_name, options: options }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def flush_pending!
|
|
18
|
+
pending.delete_if do |entry|
|
|
19
|
+
owner = entry[:plan_owner_class]
|
|
20
|
+
assoc = owner.reflect_on_association(entry[:association_name])
|
|
21
|
+
next false unless assoc
|
|
22
|
+
|
|
23
|
+
begin
|
|
24
|
+
child_klass = assoc.klass
|
|
25
|
+
child_klass.include PricingPlans::Limitable unless child_klass.ancestors.include?(PricingPlans::Limitable)
|
|
26
|
+
opts = entry[:options]
|
|
27
|
+
limit_key = (opts[:limit_key] || entry[:association_name]).to_sym
|
|
28
|
+
# Define sugar methods on the plan owner when the association resolves
|
|
29
|
+
PricingPlans::PlanOwner.define_limit_sugar_methods(owner, limit_key)
|
|
30
|
+
child_klass.limited_by_pricing_plans(
|
|
31
|
+
limit_key,
|
|
32
|
+
plan_owner: child_klass.reflections.values.find { |r| r.macro == :belongs_to && r.foreign_key.to_s == assoc.foreign_key.to_s }&.name || owner.name.underscore.to_sym,
|
|
33
|
+
per: opts[:per],
|
|
34
|
+
error_after_limit: opts[:error_after_limit],
|
|
35
|
+
count_scope: opts[:count_scope]
|
|
36
|
+
)
|
|
37
|
+
true
|
|
38
|
+
rescue StandardError
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "dsl"
|
|
4
|
+
|
|
5
|
+
module PricingPlans
|
|
6
|
+
class Configuration
|
|
7
|
+
include DSL
|
|
8
|
+
|
|
9
|
+
attr_accessor :default_plan, :highlighted_plan, :period_cycle
|
|
10
|
+
# Optional ergonomics
|
|
11
|
+
attr_accessor :default_cta_text, :default_cta_url
|
|
12
|
+
# Global controller ergonomics
|
|
13
|
+
# Optional global resolver for controller plan owner. Per-controller settings still win.
|
|
14
|
+
# Accepts:
|
|
15
|
+
# - Symbol: a controller helper to call (e.g., :current_organization)
|
|
16
|
+
# - Proc: instance-exec'd in the controller (self is the controller)
|
|
17
|
+
attr_reader :controller_plan_owner_method, :controller_plan_owner_proc
|
|
18
|
+
# When a limit check blocks, controllers can redirect to a global default target.
|
|
19
|
+
# Accepts:
|
|
20
|
+
# - Symbol: a controller helper to call (e.g., :pricing_path)
|
|
21
|
+
# - String: an absolute/relative path or full URL
|
|
22
|
+
# - Proc: instance-exec'd in the controller (self is the controller). Signature: ->(result) { ... }
|
|
23
|
+
# Result contains: limit_key, plan_owner, message, metadata
|
|
24
|
+
attr_accessor :redirect_on_blocked_limit
|
|
25
|
+
# Optional global message builder proc for human copy (i18n/hooks)
|
|
26
|
+
# Signature suggestion: (context:, **kwargs) -> string
|
|
27
|
+
# Contexts used: :over_limit, :grace, :feature_denied
|
|
28
|
+
# Example kwargs: limit_key:, current_usage:, limit_amount:, grace_ends_at:, feature_key:, plan_name:
|
|
29
|
+
attr_accessor :message_builder
|
|
30
|
+
attr_reader :plan_owner_class
|
|
31
|
+
# Optional: custom resolver for displaying price labels from processor
|
|
32
|
+
# Signature: ->(plan) { "${amount}/mo" }
|
|
33
|
+
attr_accessor :price_label_resolver
|
|
34
|
+
# Auto-fetch price labels from processor when possible (Stripe via stripe-ruby)
|
|
35
|
+
attr_accessor :auto_price_labels_from_processor
|
|
36
|
+
# Semantic pricing components resolver hook: ->(plan, interval) { PriceComponents | nil }
|
|
37
|
+
attr_accessor :price_components_resolver
|
|
38
|
+
# Default currency symbol when Stripe isn't available
|
|
39
|
+
attr_accessor :default_currency_symbol
|
|
40
|
+
# Cache for Stripe prices. Defaults to in-memory store if nil. Should respond to read/write with ttl.
|
|
41
|
+
attr_accessor :price_cache
|
|
42
|
+
# Seconds for cache TTL for Stripe lookups
|
|
43
|
+
attr_accessor :price_cache_ttl
|
|
44
|
+
# Optional free caption copy (UI copy holder)
|
|
45
|
+
attr_accessor :free_price_caption
|
|
46
|
+
# Optional default interval for UI toggles
|
|
47
|
+
attr_accessor :interval_default_for_ui
|
|
48
|
+
# Optional downgrade policy hook for CTA ergonomics
|
|
49
|
+
# Signature: ->(from:, to:, plan_owner:) { [allowed_boolean, reason_string_or_nil] }
|
|
50
|
+
attr_accessor :downgrade_policy
|
|
51
|
+
attr_reader :plans, :event_handlers
|
|
52
|
+
|
|
53
|
+
def initialize
|
|
54
|
+
@plan_owner_class = nil
|
|
55
|
+
@default_plan = nil
|
|
56
|
+
@highlighted_plan = nil
|
|
57
|
+
@period_cycle = :billing_cycle
|
|
58
|
+
@default_cta_text = nil
|
|
59
|
+
@default_cta_url = nil
|
|
60
|
+
@message_builder = nil
|
|
61
|
+
@controller_plan_owner_method = nil
|
|
62
|
+
@controller_plan_owner_proc = nil
|
|
63
|
+
@redirect_on_blocked_limit = nil
|
|
64
|
+
@price_label_resolver = nil
|
|
65
|
+
@auto_price_labels_from_processor = true
|
|
66
|
+
@price_components_resolver = nil
|
|
67
|
+
@default_currency_symbol = "$"
|
|
68
|
+
@price_cache = (defined?(Rails) && Rails.respond_to?(:cache)) ? Rails.cache : nil
|
|
69
|
+
@price_cache_ttl = 600 # 10 minutes
|
|
70
|
+
@free_price_caption = "Forever free"
|
|
71
|
+
@interval_default_for_ui = :month
|
|
72
|
+
@downgrade_policy = ->(from:, to:, plan_owner:) { [true, nil] }
|
|
73
|
+
@plans = {}
|
|
74
|
+
@event_handlers = {
|
|
75
|
+
warning: {},
|
|
76
|
+
grace_start: {},
|
|
77
|
+
block: {}
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def plan_owner_class=(value)
|
|
82
|
+
if value.nil?
|
|
83
|
+
@plan_owner_class = nil
|
|
84
|
+
return
|
|
85
|
+
end
|
|
86
|
+
unless value.is_a?(String) || value.is_a?(Class)
|
|
87
|
+
raise PricingPlans::ConfigurationError, "plan_owner_class must be a string or class"
|
|
88
|
+
end
|
|
89
|
+
@plan_owner_class = value
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def plan(key, &block)
|
|
93
|
+
raise PricingPlans::ConfigurationError, "Plan key must be a symbol" unless key.is_a?(Symbol)
|
|
94
|
+
raise PricingPlans::ConfigurationError, "Plan #{key} already defined" if @plans.key?(key)
|
|
95
|
+
|
|
96
|
+
plan_instance = PricingPlans::Plan.new(key)
|
|
97
|
+
plan_instance.instance_eval(&block)
|
|
98
|
+
@plans[key] = plan_instance
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# Global controller plan owner resolver API
|
|
103
|
+
# Usage:
|
|
104
|
+
# config.controller_plan_owner :current_organization
|
|
105
|
+
# # or
|
|
106
|
+
# config.controller_plan_owner { current_account }
|
|
107
|
+
def controller_plan_owner(method_name = nil, &block)
|
|
108
|
+
if method_name
|
|
109
|
+
@controller_plan_owner_method = method_name.to_sym
|
|
110
|
+
@controller_plan_owner_proc = nil
|
|
111
|
+
elsif block_given?
|
|
112
|
+
@controller_plan_owner_proc = block
|
|
113
|
+
@controller_plan_owner_method = nil
|
|
114
|
+
else
|
|
115
|
+
@controller_plan_owner_method
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def on_warning(limit_key, &block)
|
|
120
|
+
raise PricingPlans::ConfigurationError, "Block required for on_warning" unless block_given?
|
|
121
|
+
@event_handlers[:warning][limit_key] = block
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def on_grace_start(limit_key, &block)
|
|
125
|
+
raise PricingPlans::ConfigurationError, "Block required for on_grace_start" unless block_given?
|
|
126
|
+
@event_handlers[:grace_start][limit_key] = block
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def on_block(limit_key, &block)
|
|
130
|
+
raise PricingPlans::ConfigurationError, "Block required for on_block" unless block_given?
|
|
131
|
+
@event_handlers[:block][limit_key] = block
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def validate!
|
|
135
|
+
select_defaults_from_dsl!
|
|
136
|
+
validate_required_settings!
|
|
137
|
+
validate_plan_references!
|
|
138
|
+
validate_dsl_markers!
|
|
139
|
+
validate_plans!
|
|
140
|
+
end
|
|
141
|
+
def select_defaults_from_dsl!
|
|
142
|
+
# If not explicitly configured, derive from any plan marked via DSL sugar
|
|
143
|
+
if @default_plan.nil?
|
|
144
|
+
dsl_default = @plans.values.find(&:default?)&.key
|
|
145
|
+
@default_plan = dsl_default if dsl_default
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
if @highlighted_plan.nil?
|
|
149
|
+
dsl_highlighted = @plans.values.find(&:highlighted?)&.key
|
|
150
|
+
@highlighted_plan = dsl_highlighted if dsl_highlighted
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def validate_dsl_markers!
|
|
155
|
+
defaults = @plans.values.select(&:default?)
|
|
156
|
+
highlights = @plans.values.select(&:highlighted?)
|
|
157
|
+
|
|
158
|
+
if defaults.size > 1
|
|
159
|
+
keys = defaults.map(&:key).join(", ")
|
|
160
|
+
raise PricingPlans::ConfigurationError, "Multiple plans marked default via DSL: #{keys}. Only one plan can be default."
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
if highlights.size > 1
|
|
164
|
+
keys = highlights.map(&:key).join(", ")
|
|
165
|
+
raise PricingPlans::ConfigurationError, "Multiple plans marked highlighted via DSL: #{keys}. Only one plan can be highlighted."
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
def validate_required_settings!
|
|
172
|
+
raise PricingPlans::ConfigurationError, "default_plan is required" unless @default_plan
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def validate_plan_references!
|
|
176
|
+
unless @plans.key?(@default_plan)
|
|
177
|
+
raise PricingPlans::ConfigurationError, "default_plan #{@default_plan} is not defined"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
if @highlighted_plan && !@plans.key?(@highlighted_plan)
|
|
181
|
+
raise PricingPlans::ConfigurationError, "highlighted_plan #{@highlighted_plan} is not defined"
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def validate_plans!
|
|
186
|
+
@plans.each_value(&:validate!)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|