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