├── lib ├── pricing_plans │ ├── version.rb │ ├── price_components.rb │ ├── job_guards.rb │ ├── dsl.rb │ ├── models │ │ ├── enforcement_state.rb │ │ ├── assignment.rb │ │ └── usage.rb │ ├── integer_refinements.rb │ ├── association_limit_registry.rb │ ├── view_helpers.rb │ ├── result.rb │ ├── overage_reporter.rb │ ├── engine.rb │ ├── plan_resolver.rb │ ├── pay_support.rb │ ├── controller_rescues.rb │ ├── limit_checker.rb │ ├── registry.rb │ ├── period_calculator.rb │ └── configuration.rb └── generators │ └── pricing_plans │ └── install │ ├── install_generator.rb │ └── templates │ ├── create_pricing_plans_tables.rb.erb │ └── initializer.rb ├── docs ├── images │ ├── product_creation_blocked.jpg │ ├── pricing_plans_ruby_rails_gem_usage_meter.jpg │ ├── pricing_plans_ruby_rails_gem_pricing_table.jpg │ └── pricing_plans_ruby_rails_gem_usage_alert_upgrade.jpg ├── 04-views.md ├── 06-gem-compatibility.md └── 05-semantic-pricing.md ├── sig └── pricing_plans.rbs ├── bin ├── setup └── console ├── Gemfile ├── CHANGELOG.md ├── Rakefile ├── .gitignore ├── .claude └── settings.local.json ├── test ├── engine_rescue_responses_test.rb ├── plans_api_test.rb ├── price_label_test.rb ├── services │ ├── overage_reporter_test.rb │ ├── plan_resolver_pay_integration_test.rb │ ├── plan_resolver_test.rb │ └── registry_test.rb ├── cta_helpers_test.rb ├── message_builder_test.rb ├── dsl_test.rb ├── controller_rescues_limit_blocked_test.rb ├── integration │ └── feature_denied_rescue_integration_test.rb ├── usage_status_helpers_test.rb ├── complex_associations_test.rb ├── integer_refinements_test.rb ├── controller_rescues_test.rb ├── plan_pricing_api_test.rb ├── price_components_test.rb ├── limitable_inference_test.rb ├── plan_owner_helpers_test.rb ├── view_helpers_test.rb ├── models │ ├── usage_test.rb │ ├── assignment_test.rb │ └── enforcement_state_test.rb ├── result_test.rb └── test_helper.rb ├── LICENSE.txt ├── .github └── workflows │ ├── claude.yml │ └── claude-code-review.yml ├── pricing_plans.gemspec ├── .rubocop.yml └── Gemfile.lock /lib/pricing_plans/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PricingPlans 4 | VERSION = "0.1.0" 5 | end 6 | -------------------------------------------------------------------------------- /docs/images/product_creation_blocked.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rameerez/pricing_plans/HEAD/docs/images/product_creation_blocked.jpg -------------------------------------------------------------------------------- /sig/pricing_plans.rbs: -------------------------------------------------------------------------------- 1 | module PricingPlans 2 | VERSION: String 3 | # See the writing guide of rbs: https://github.com/ruby/rbs#guides 4 | end 5 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /docs/images/pricing_plans_ruby_rails_gem_usage_meter.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rameerez/pricing_plans/HEAD/docs/images/pricing_plans_ruby_rails_gem_usage_meter.jpg -------------------------------------------------------------------------------- /docs/images/pricing_plans_ruby_rails_gem_pricing_table.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rameerez/pricing_plans/HEAD/docs/images/pricing_plans_ruby_rails_gem_pricing_table.jpg -------------------------------------------------------------------------------- /docs/images/pricing_plans_ruby_rails_gem_usage_alert_upgrade.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rameerez/pricing_plans/HEAD/docs/images/pricing_plans_ruby_rails_gem_usage_alert_upgrade.jpg -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in pricing_plans.gemspec 6 | gemspec 7 | 8 | gem "irb" 9 | gem "rake", "~> 13.0" 10 | gem "ostruct" 11 | gem "simplecov", require: false 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.1.0] - 2025-08-19 9 | 10 | Initial release -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "pricing_plans" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | require "irb" 11 | IRB.start(__FILE__) 12 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rake/testtask" 5 | require "rubocop/rake_task" 6 | 7 | Rake::TestTask.new(:test) do |t| 8 | t.libs << "test" 9 | t.libs << "lib" 10 | t.test_files = FileList["test/**/*_test.rb"] 11 | end 12 | 13 | RuboCop::RakeTask.new 14 | 15 | task default: %i[test rubocop] 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | /vendor/ 10 | /vendor/bundle/ 11 | /vendor/cache/ 12 | /vendor/assets/ 13 | .bundle/ 14 | /yarn-error.log 15 | /node_modules/ 16 | /.ruby-lsp/ 17 | /.solargraph.yml 18 | 19 | .aux/ 20 | .docs/ 21 | 22 | .vscode/ 23 | .cursor/ 24 | .claude/ 25 | 26 | TODO 27 | 28 | dist/ -------------------------------------------------------------------------------- /.claude/settings.local.json: -------------------------------------------------------------------------------- 1 | { 2 | "permissions": { 3 | "allow": [ 4 | "Bash(mkdir:*)", 5 | "Bash(bundle install:*)", 6 | "Bash(bundle exec rake:*)", 7 | "Bash(bundle update:*)", 8 | "Bash(bundle exec ruby:*)", 9 | "Bash(sed:*)", 10 | "Bash(grep:*)", 11 | "Bash(ruby:*)", 12 | "Bash(find:*)" 13 | ], 14 | "deny": [] 15 | } 16 | } -------------------------------------------------------------------------------- /test/engine_rescue_responses_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class EngineRescueResponsesTest < ActiveSupport::TestCase 6 | def test_feature_denied_is_mapped_to_403 7 | # In this minimal test environment we don't have a full Rails::Engine instance. 8 | # This test serves as a smoke test that the constant exists and the initializer code compiles. 9 | assert defined?(PricingPlans::FeatureDenied) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/pricing_plans/price_components.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PricingPlans 4 | # Pure-data value object describing a plan price in semantic parts. 5 | # UI-agnostic. Useful to render classic pricing typography and power JS toggles. 6 | PriceComponents = Struct.new( 7 | :present?, # boolean: true when numeric price is available 8 | :currency, # String: currency symbol, e.g. "$", "€" 9 | :amount, # String: human whole amount (no decimals) e.g. "29" 10 | :amount_cents, # Integer: total cents e.g. 2900 11 | :interval, # Symbol: :month or :year 12 | :label, # String: friendly label e.g. "$29/mo" or "Contact" 13 | :monthly_equivalent_cents, # Integer: same-month or yearly/12 rounded 14 | keyword_init: true 15 | ) 16 | end 17 | -------------------------------------------------------------------------------- /lib/pricing_plans/job_guards.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PricingPlans 4 | # Lightweight ergonomics for background jobs and services 5 | module JobGuards 6 | module_function 7 | 8 | # Runs the given block only if within limit or when system override is allowed. 9 | # Returns the Result in all cases so callers can inspect state. 10 | # Usage: 11 | # PricingPlans::JobGuards.with_plan_limit(:licenses, plan_owner: org, by: 1, allow_system_override: true) do |result| 12 | # # perform work; result.warning?/grace? can be surfaced 13 | # end 14 | def with_plan_limit(limit_key, plan_owner:, by: 1, allow_system_override: false) 15 | result = ControllerGuards.require_plan_limit!(limit_key, plan_owner: plan_owner, by: by, allow_system_override: allow_system_override) 16 | 17 | blocked_without_override = result.blocked? && !(allow_system_override && result.metadata && result.metadata[:system_override]) 18 | return result if blocked_without_override 19 | 20 | yield(result) if block_given? 21 | result 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/pricing_plans/dsl.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PricingPlans 4 | module DSL 5 | # This module provides common DSL functionality that can be included 6 | # in other classes to provide a consistent interface 7 | 8 | # Period constants for easy reference 9 | PERIOD_OPTIONS = [ 10 | :billing_cycle, 11 | :calendar_month, 12 | :calendar_week, 13 | :calendar_day, 14 | :month, 15 | :week, 16 | :day 17 | ].freeze 18 | 19 | private 20 | 21 | def validate_period_option(period) 22 | return true if period.respond_to?(:call) # Custom callable 23 | return true if PERIOD_OPTIONS.include?(period) 24 | 25 | # Allow ActiveSupport duration objects 26 | return true if period.respond_to?(:seconds) 27 | 28 | false 29 | end 30 | 31 | def normalize_period(period) 32 | case period 33 | when :month 34 | :calendar_month 35 | when :week 36 | :calendar_week 37 | when :day 38 | :calendar_day 39 | else 40 | period 41 | end 42 | end 43 | end 44 | end -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 Javi R 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/plans_api_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class PlansApiTest < ActiveSupport::TestCase 6 | def setup 7 | super 8 | PricingPlans.reset_configuration! 9 | PricingPlans.configure do |config| 10 | config.plan :free do 11 | price 0 12 | default! 13 | end 14 | config.plan :basic do 15 | price 10 16 | end 17 | config.plan :pro do 18 | price 20 19 | highlighted! 20 | end 21 | config.plan :enterprise do 22 | price_string "Contact" 23 | end 24 | end 25 | end 26 | 27 | def test_plans_returns_array_sorted 28 | keys = PricingPlans.plans.map(&:key) 29 | assert_equal [:free, :basic, :pro, :enterprise], keys 30 | end 31 | 32 | def test_plans_api_returns_sorted_plans 33 | array = PricingPlans.plans 34 | assert array.is_a?(Array) 35 | assert array.first.is_a?(PricingPlans::Plan) 36 | end 37 | 38 | def test_suggest_next_plan_for_progression 39 | org = create_organization 40 | # free should satisfy zero usage 41 | assert_equal :free, PricingPlans.suggest_next_plan_for(org, keys: [:projects]).key 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/pricing_plans/models/enforcement_state.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PricingPlans 4 | class EnforcementState < ActiveRecord::Base 5 | self.table_name = "pricing_plans_enforcement_states" 6 | 7 | belongs_to :plan_owner, polymorphic: true 8 | 9 | validates :limit_key, presence: true 10 | validates :plan_owner_type, :plan_owner_id, :limit_key, uniqueness: { scope: [:plan_owner_type, :plan_owner_id] } 11 | 12 | scope :exceeded, -> { where.not(exceeded_at: nil) } 13 | scope :blocked, -> { where.not(blocked_at: nil) } 14 | scope :in_grace, -> { exceeded.where(blocked_at: nil) } 15 | 16 | def exceeded? 17 | exceeded_at.present? 18 | end 19 | 20 | def blocked? 21 | blocked_at.present? 22 | end 23 | 24 | def in_grace? 25 | exceeded? && !blocked? 26 | end 27 | 28 | def grace_ends_at 29 | return nil unless exceeded_at && grace_period 30 | exceeded_at + grace_period 31 | end 32 | 33 | def grace_expired? 34 | return false unless grace_ends_at 35 | Time.current >= grace_ends_at 36 | end 37 | 38 | private 39 | 40 | def grace_period 41 | # This will be set by the GraceManager based on the plan configuration 42 | data&.dig("grace_period")&.seconds 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/price_label_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class PriceLabelTest < ActiveSupport::TestCase 6 | def setup 7 | super 8 | PricingPlans.reset_configuration! 9 | PricingPlans.configure do |config| 10 | config.default_plan = :pro 11 | config.plan :pro do 12 | stripe_price "price_abc" 13 | end 14 | end 15 | @plan = PricingPlans::Registry.plan(:pro) 16 | end 17 | 18 | def test_price_label_falls_back_when_no_stripe 19 | # No Stripe constant defined; should fall back to Contact for stripe_price plans 20 | assert_equal "Contact", @plan.price_label 21 | end 22 | 23 | def test_price_label_auto_fetches_from_stripe_when_available 24 | # Define a minimal Stripe stub only for this test 25 | stripe_mod = Module.new 26 | price_class = Class.new do 27 | def self.retrieve(_id) 28 | recurring = Struct.new(:interval).new("month") 29 | Struct.new(:unit_amount, :recurring).new(2_900, recurring) 30 | end 31 | end 32 | stripe_mod.const_set(:Price, price_class) 33 | Object.const_set(:Stripe, stripe_mod) 34 | 35 | begin 36 | label = @plan.price_label 37 | assert_match(/\$29(\.0)?\/mon/, label) 38 | ensure 39 | Object.send(:remove_const, :Stripe) 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/pricing_plans/integer_refinements.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PricingPlans 4 | # Refinements for Integer to provide DSL sugar like `5.max` 5 | # This is scoped only to our DSL usage to avoid polluting the global namespace 6 | module IntegerRefinements 7 | refine Integer do 8 | def max 9 | self 10 | end 11 | 12 | # Additional convenience methods for time periods that read well in DSL 13 | alias_method :day, :day if method_defined?(:day) 14 | alias_method :days, :days if method_defined?(:days) 15 | alias_method :week, :week if method_defined?(:week) 16 | alias_method :weeks, :weeks if method_defined?(:weeks) 17 | alias_method :month, :month if method_defined?(:month) 18 | alias_method :months, :months if method_defined?(:months) 19 | 20 | # If ActiveSupport isn't loaded, provide basic duration support 21 | unless method_defined?(:days) 22 | def days 23 | self * 86400 # seconds in a day 24 | end 25 | 26 | def day 27 | days 28 | end 29 | 30 | def weeks 31 | days * 7 32 | end 33 | 34 | def week 35 | weeks 36 | end 37 | 38 | def months 39 | days * 30 # approximate for basic support 40 | end 41 | 42 | def month 43 | months 44 | end 45 | end 46 | end 47 | end 48 | end -------------------------------------------------------------------------------- /test/services/overage_reporter_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class OverageReporterTest < ActiveSupport::TestCase 6 | def test_overage_report_for_persistent_and_per_period 7 | org = create_organization 8 | 9 | # Persistent cap: projects 10 | PricingPlans::Assignment.assign_plan_to(org, :enterprise) 11 | org.projects.create!(name: "P1") 12 | org.projects.create!(name: "P2") 13 | 14 | # Target: free plan allows 1 project → over by 1 15 | items = PricingPlans::OverageReporter.report(org, :free) 16 | projects_item = items.find { |i| i.limit_key == :projects } 17 | 18 | assert projects_item, "Expected projects overage" 19 | assert_equal :persistent, projects_item.kind 20 | assert_equal 2, projects_item.current_usage 21 | assert_equal 1, projects_item.allowed 22 | assert_equal 1, projects_item.overage 23 | 24 | # Per period: custom_models (free allows 0, pro allows 3); assign pro, use 3, then report vs free 25 | PricingPlans::Assignment.assign_plan_to(org, :pro) 26 | 3.times { |i| org.custom_models.create!(name: "M#{i}") } 27 | 28 | items = PricingPlans::OverageReporter.report(org, :free) 29 | custom_item = items.find { |i| i.limit_key == :custom_models } 30 | assert custom_item, "Expected custom_models overage" 31 | assert_equal :per_period, custom_item.kind 32 | assert custom_item.current_usage >= 3 33 | assert_equal 0, custom_item.allowed 34 | assert custom_item.overage >= 3 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/pricing_plans/models/assignment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PricingPlans 4 | class Assignment < ActiveRecord::Base 5 | self.table_name = "pricing_plans_assignments" 6 | 7 | belongs_to :plan_owner, polymorphic: true 8 | 9 | validates :plan_owner, presence: true 10 | validates :plan_key, presence: true 11 | validates :source, presence: true 12 | validates :plan_owner_type, uniqueness: { scope: :plan_owner_id } 13 | 14 | validate :plan_exists_in_registry 15 | 16 | scope :manual, -> { where(source: "manual") } 17 | scope :for_plan, ->(plan_key) { where(plan_key: plan_key.to_s) } 18 | 19 | def plan 20 | Registry.plan(plan_key.to_sym) 21 | end 22 | 23 | def self.assign_plan_to(plan_owner, plan_key, source: "manual") 24 | assignment = find_or_initialize_by( 25 | plan_owner_type: plan_owner.class.name, 26 | plan_owner_id: plan_owner.id 27 | ) 28 | 29 | assignment.assign_attributes( 30 | plan_key: plan_key.to_s, 31 | source: source.to_s 32 | ) 33 | 34 | assignment.save! 35 | assignment 36 | end 37 | 38 | def self.remove_assignment_for(plan_owner) 39 | where( 40 | plan_owner_type: plan_owner.class.name, 41 | plan_owner_id: plan_owner.id 42 | ).destroy_all 43 | end 44 | 45 | private 46 | 47 | def plan_exists_in_registry 48 | return unless plan_key.present? 49 | 50 | unless Registry.plan_exists?(plan_key) 51 | errors.add(:plan_key, "#{plan_key} is not a defined plan") 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/pricing_plans/models/usage.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PricingPlans 4 | class Usage < ActiveRecord::Base 5 | self.table_name = "pricing_plans_usages" 6 | 7 | belongs_to :plan_owner, polymorphic: true 8 | 9 | validates :limit_key, presence: true 10 | validates :period_start, :period_end, presence: true 11 | validates :used, presence: true, numericality: { greater_than_or_equal_to: 0 } 12 | validates :period_start, uniqueness: { scope: [:plan_owner_type, :plan_owner_id, :limit_key] } 13 | 14 | validate :period_end_after_start 15 | 16 | scope :current_period, ->(period_start, period_end) { 17 | where(period_start: period_start, period_end: period_end) 18 | } 19 | 20 | scope :for_limit, ->(limit_key) { where(limit_key: limit_key.to_s) } 21 | 22 | def increment!(amount = 1) 23 | increment(:used, amount) 24 | update!(last_used_at: Time.current) 25 | end 26 | 27 | def within_period?(timestamp = Time.current) 28 | timestamp >= period_start && timestamp < period_end 29 | end 30 | 31 | def remaining(limit_amount) 32 | return Float::INFINITY if limit_amount == :unlimited 33 | [0, limit_amount - used].max 34 | end 35 | 36 | def percent_used(limit_amount) 37 | return 0.0 if limit_amount == :unlimited || limit_amount.zero? 38 | [(used.to_f / limit_amount) * 100, 100.0].min 39 | end 40 | 41 | private 42 | 43 | def period_end_after_start 44 | return unless period_start && period_end 45 | 46 | if period_end <= period_start 47 | errors.add(:period_end, "must be after period_start") 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/generators/pricing_plans/install/install_generator.rb: -------------------------------------------------------------------------------- 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 43 | -------------------------------------------------------------------------------- /lib/pricing_plans/association_limit_registry.rb: -------------------------------------------------------------------------------- 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 46 | -------------------------------------------------------------------------------- /test/cta_helpers_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class CtaHelpersTest < ActiveSupport::TestCase 6 | 7 | def setup 8 | super 9 | PricingPlans.reset_configuration! 10 | PricingPlans.configure do |config| 11 | config.default_plan = :pro 12 | config.plan :pro do 13 | stripe_price "price_abc" 14 | end 15 | end 16 | @org = create_organization 17 | @plan = PricingPlans::Registry.plan(:pro) 18 | end 19 | 20 | def test_cta_url_setter_and_getter 21 | @plan.cta_url "/checkout" 22 | assert_equal "/checkout", @plan.cta_url 23 | end 24 | 25 | def test_cta_url_resolver_prefers_default_cta_url_when_set 26 | PricingPlans.configuration.default_cta_url = "/pricing" 27 | assert_equal "/pricing", @plan.cta_url(plan_owner: @org) 28 | ensure 29 | PricingPlans.configuration.default_cta_url = nil 30 | end 31 | 32 | def test_cta_url_uses_conventional_subscribe_path_when_available 33 | # Simulate presence of subscribe_path in host app, but do not leak to other tests 34 | original_helpers = nil 35 | created_rails = false 36 | begin 37 | mod = Module.new do 38 | def self.subscribe_path(plan:, interval:) 39 | "/subscribe?plan=#{plan}&interval=#{interval}" 40 | end 41 | end 42 | 43 | if defined?(Rails) 44 | original_helpers = Rails.application.routes.url_helpers if Rails.application && Rails.application.routes 45 | Rails.application.routes.define_singleton_method(:url_helpers) { mod } 46 | else 47 | Object.const_set(:Rails, Module.new) 48 | created_rails = true 49 | app = Module.new 50 | app.define_singleton_method(:routes) { OpenStruct.new(url_helpers: mod) } 51 | Rails.define_singleton_method(:application) { OpenStruct.new(routes: app.routes) } 52 | end 53 | 54 | url = @plan.cta_url(plan_owner: @org) 55 | assert_equal "/subscribe?plan=pro&interval=month", url 56 | ensure 57 | if created_rails 58 | Object.send(:remove_const, :Rails) 59 | elsif original_helpers 60 | Rails.application.routes.define_singleton_method(:url_helpers) { original_helpers } 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /test/message_builder_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class MessageBuilderTest < ActiveSupport::TestCase 6 | def setup 7 | super 8 | PricingPlans.reset_configuration! 9 | PricingPlans.configure do |config| 10 | config.plan :free do 11 | price 0 12 | limits :projects, to: 1, after_limit: :grace_then_block, grace: 7.days 13 | default! 14 | end 15 | config.plan :pro do 16 | price 20 17 | highlighted! 18 | limits :projects, to: 10 19 | end 20 | end 21 | # Re-register counters after config reset 22 | Project.send(:limited_by_pricing_plans, :projects, plan_owner: :organization) if Project.respond_to?(:limited_by_pricing_plans) 23 | @org = create_organization 24 | @builder_calls = [] 25 | PricingPlans.configuration.message_builder = ->(**kwargs) do 26 | @builder_calls << kwargs 27 | "Built: #{kwargs[:context]}" 28 | end 29 | end 30 | 31 | def test_feature_denied_uses_message_builder 32 | error = assert_raises(PricingPlans::FeatureDenied) do 33 | PricingPlans::ControllerGuards.require_feature!(:api_access, plan_owner: @org) 34 | end 35 | assert_match(/Built: feature_denied/, error.message) 36 | assert @builder_calls.any? { |k| k[:context] == :feature_denied } 37 | end 38 | 39 | def test_over_limit_and_grace_messages_use_message_builder 40 | # Hit the limit to trigger grace path 41 | # free allows 1 project; create 2 and then require by 1 should exceed 42 | 2.times { |i| @org.projects.create!(name: "P#{i}") } 43 | res = PricingPlans::ControllerGuards.require_plan_limit!(:projects, plan_owner: @org, by: 1) 44 | assert (res.warning? || res.grace? || res.blocked?), "expected non-within result" 45 | used_contexts = @builder_calls.map { |k| k[:context] }.uniq 46 | assert used_contexts.include?(:over_limit) || used_contexts.include?(:grace) 47 | end 48 | 49 | def test_overage_report_message_builder 50 | # Increase usage for target plan overage 51 | 5.times { |i| @org.projects.create!(name: "Q#{i}") } 52 | report = PricingPlans::OverageReporter.report_with_message(@org, :free) 53 | assert_match(/Built: overage_report/, report.message) 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/pricing_plans/view_helpers.rb: -------------------------------------------------------------------------------- 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 59 | -------------------------------------------------------------------------------- /test/dsl_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class DSLTest < ActiveSupport::TestCase 6 | include PricingPlans::DSL 7 | 8 | def test_period_options_constant_is_frozen 9 | assert PricingPlans::DSL::PERIOD_OPTIONS.frozen? 10 | assert_includes PricingPlans::DSL::PERIOD_OPTIONS, :billing_cycle 11 | assert_includes PricingPlans::DSL::PERIOD_OPTIONS, :calendar_month 12 | assert_includes PricingPlans::DSL::PERIOD_OPTIONS, :calendar_week 13 | assert_includes PricingPlans::DSL::PERIOD_OPTIONS, :calendar_day 14 | end 15 | 16 | def test_validate_period_option_with_valid_symbols 17 | assert validate_period_option(:billing_cycle) 18 | assert validate_period_option(:calendar_month) 19 | assert validate_period_option(:calendar_week) 20 | assert validate_period_option(:calendar_day) 21 | assert validate_period_option(:month) 22 | assert validate_period_option(:week) 23 | assert validate_period_option(:day) 24 | end 25 | 26 | def test_validate_period_option_with_callable 27 | assert validate_period_option(-> { "test" }) 28 | assert validate_period_option(proc { "test" }) 29 | 30 | callable_object = Object.new 31 | def callable_object.call; end 32 | assert validate_period_option(callable_object) 33 | end 34 | 35 | def test_validate_period_option_with_duration_objects 36 | assert validate_period_option(1.day) 37 | assert validate_period_option(7.days) 38 | assert validate_period_option(1.month) 39 | end 40 | 41 | def test_validate_period_option_with_invalid_values 42 | refute validate_period_option(:invalid_period) 43 | refute validate_period_option("string") 44 | refute validate_period_option(Object.new) 45 | 46 | # Note: Integer responds to :seconds due to ActiveSupport, so numbers are valid 47 | end 48 | 49 | def test_normalize_period_shortcuts 50 | assert_equal :calendar_month, normalize_period(:month) 51 | assert_equal :calendar_week, normalize_period(:week) 52 | assert_equal :calendar_day, normalize_period(:day) 53 | end 54 | 55 | def test_normalize_period_passes_through_other_values 56 | assert_equal :billing_cycle, normalize_period(:billing_cycle) 57 | assert_equal :calendar_month, normalize_period(:calendar_month) 58 | assert_equal "custom", normalize_period("custom") 59 | 60 | callable = -> { "test" } 61 | assert_equal callable, normalize_period(callable) 62 | end 63 | end -------------------------------------------------------------------------------- /.github/workflows/claude.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code 2 | 3 | on: 4 | issue_comment: 5 | types: [created] 6 | pull_request_review_comment: 7 | types: [created] 8 | issues: 9 | types: [opened, assigned] 10 | pull_request_review: 11 | types: [submitted] 12 | 13 | jobs: 14 | claude: 15 | if: | 16 | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || 17 | (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || 18 | (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || 19 | (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: read 23 | pull-requests: read 24 | issues: read 25 | id-token: write 26 | actions: read # Required for Claude to read CI results on PRs 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v4 30 | with: 31 | fetch-depth: 1 32 | 33 | - name: Run Claude Code 34 | id: claude 35 | uses: anthropics/claude-code-action@beta 36 | with: 37 | claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} 38 | 39 | # This is an optional setting that allows Claude to read CI results on PRs 40 | additional_permissions: | 41 | actions: read 42 | 43 | # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1) 44 | # model: "claude-opus-4-1-20250805" 45 | 46 | # Optional: Customize the trigger phrase (default: @claude) 47 | # trigger_phrase: "/claude" 48 | 49 | # Optional: Trigger when specific user is assigned to an issue 50 | # assignee_trigger: "claude-bot" 51 | 52 | # Optional: Allow Claude to run specific commands 53 | # allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)" 54 | 55 | # Optional: Add custom instructions for Claude to customize its behavior for your project 56 | # custom_instructions: | 57 | # Follow our coding standards 58 | # Ensure all new code has tests 59 | # Use TypeScript for new files 60 | 61 | # Optional: Custom environment variables for Claude 62 | # claude_env: | 63 | # NODE_ENV: test 64 | 65 | -------------------------------------------------------------------------------- /test/controller_rescues_limit_blocked_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class ControllerRescuesLimitBlockedTest < ActiveSupport::TestCase 6 | class DummyController 7 | include PricingPlans::ControllerRescues 8 | 9 | attr_accessor :_request_format, :_flash, :_redirected_to, :_rendered 10 | 11 | def initialize 12 | @_request_format = :html 13 | @_flash = {} 14 | @_redirected_to = nil 15 | @_rendered = nil 16 | end 17 | 18 | def request 19 | OpenStruct.new(format: OpenStruct.new(html?: (_request_format == :html), json?: (_request_format == :json))) 20 | end 21 | 22 | def flash 23 | @_flash 24 | end 25 | 26 | def pricing_path 27 | "/pricing" 28 | end 29 | 30 | def redirect_to(path, status: :see_other, allow_other_host: false) 31 | @_redirected_to = [path, status, allow_other_host] 32 | end 33 | 34 | def render(status:, plain: nil, json: nil) 35 | @_rendered = [status, plain, json] 36 | end 37 | end 38 | 39 | def test_handle_pricing_plans_limit_blocked_html 40 | ctrl = DummyController.new 41 | result = PricingPlans::Result.blocked("blocked!", limit_key: :projects, plan_owner: create_organization) 42 | ctrl.send(:handle_pricing_plans_limit_blocked, result) 43 | assert_equal ["/pricing", :see_other, false], ctrl._redirected_to 44 | assert_match(/blocked!/i, ctrl._flash[:alert]) 45 | end 46 | 47 | def test_handle_pricing_plans_limit_blocked_html_prefers_redirect_from_metadata 48 | ctrl = DummyController.new 49 | result = PricingPlans::Result.blocked( 50 | "blocked!", 51 | limit_key: :projects, 52 | plan_owner: create_organization, 53 | metadata: { redirect_to: "/override" } 54 | ) 55 | ctrl.send(:handle_pricing_plans_limit_blocked, result) 56 | assert_equal ["/override", :see_other, false], ctrl._redirected_to 57 | assert_match(/blocked!/i, ctrl._flash[:alert]) 58 | end 59 | 60 | def test_handle_pricing_plans_limit_blocked_json 61 | ctrl = DummyController.new 62 | ctrl._request_format = :json 63 | result = PricingPlans::Result.blocked("blocked!", limit_key: :projects, plan_owner: create_organization) 64 | ctrl.send(:handle_pricing_plans_limit_blocked, result) 65 | # Default config picks a default plan name; accept any string for plan 66 | assert_equal :forbidden, ctrl._rendered[0] 67 | assert_nil ctrl._rendered[1] 68 | assert_equal "blocked!", ctrl._rendered[2][:error] 69 | assert_equal :projects, ctrl._rendered[2][:limit] 70 | assert_kind_of String, ctrl._rendered[2][:plan] 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /test/integration/feature_denied_rescue_integration_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | # A light integration-style test around the controller guards + rescues. 6 | class FeatureDeniedRescueIntegrationTest < ActiveSupport::TestCase 7 | class DummyController 8 | include PricingPlans::ControllerGuards 9 | include PricingPlans::ControllerRescues 10 | 11 | def initialize(plan_owner:, format: :json) 12 | @plan_owner = plan_owner 13 | @format = format 14 | end 15 | 16 | def current_organization 17 | @plan_owner 18 | end 19 | 20 | # Minimal surface needed by the rescue module 21 | def request 22 | @request ||= Struct.new(:format).new(Format.new(@format)) 23 | end 24 | 25 | class Format 26 | def initialize(sym) 27 | @sym = sym 28 | end 29 | def html?; @sym == :html; end 30 | def json?; @sym == :json; end 31 | end 32 | 33 | attr_reader :result 34 | 35 | def render(**kwargs) 36 | @result = { action: :render, kwargs: kwargs } 37 | end 38 | 39 | def redirect_to(path, **kwargs) 40 | @result = { action: :redirect_to, path: path, kwargs: kwargs, flash: @flash } 41 | end 42 | 43 | def pricing_path 44 | "/pricing" 45 | end 46 | 47 | def flash 48 | @flash ||= {} 49 | end 50 | 51 | # Simulate a before_action usage 52 | def gated 53 | enforce_api_access!(for: :current_organization) 54 | :ok 55 | rescue PricingPlans::FeatureDenied => e 56 | # Let the included rescue handle it 57 | send(:handle_pricing_plans_feature_denied, e) 58 | end 59 | end 60 | 61 | def setup 62 | super 63 | @org = create_organization 64 | end 65 | 66 | def test_json_gated_returns_403_payload 67 | controller = DummyController.new(plan_owner: @org, format: :json) 68 | controller.gated 69 | assert_equal :render, controller.result[:action] 70 | assert_equal :forbidden, controller.result[:kwargs][:status] 71 | payload = controller.result[:kwargs][:json] 72 | assert_match(/upgrade|not available/i, payload[:error]) 73 | # In our tiny controller we do not provide plan_owner to the exception, so feature may be nil here 74 | end 75 | 76 | def test_html_gated_redirects_to_pricing 77 | controller = DummyController.new(plan_owner: @org, format: :html) 78 | controller.gated 79 | assert_equal :redirect_to, controller.result[:action] 80 | assert_equal "/pricing", controller.result[:path] 81 | assert_equal :see_other, controller.result[:kwargs][:status] 82 | assert_match(/your current plan/i, controller.result[:flash][:alert]) 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/pricing_plans/result.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PricingPlans 4 | class Result 5 | STATES = [:within, :warning, :grace, :blocked].freeze 6 | 7 | attr_reader :state, :message, :limit_key, :plan_owner, :metadata 8 | 9 | def initialize(state:, message:, limit_key: nil, plan_owner: nil, metadata: {}) 10 | unless STATES.include?(state) 11 | raise ArgumentError, "Invalid state: #{state}. Must be one of: #{STATES}" 12 | end 13 | 14 | @state = state 15 | @message = message 16 | @limit_key = limit_key 17 | @plan_owner = plan_owner 18 | @metadata = metadata 19 | end 20 | 21 | def ok? 22 | @state == :within 23 | end 24 | 25 | alias_method :within?, :ok? 26 | 27 | def warning? 28 | @state == :warning 29 | end 30 | 31 | def grace? 32 | @state == :grace 33 | end 34 | 35 | def blocked? 36 | @state == :blocked 37 | end 38 | 39 | def success? 40 | ok? || warning? || grace? 41 | end 42 | 43 | def failure? 44 | blocked? 45 | end 46 | 47 | # Helper methods for view rendering 48 | def css_class 49 | case @state 50 | when :within 51 | "success" 52 | when :warning 53 | "warning" 54 | when :grace 55 | "warning" 56 | when :blocked 57 | "error" 58 | end 59 | end 60 | 61 | def icon 62 | case @state 63 | when :within 64 | "✓" 65 | when :warning 66 | "⚠" 67 | when :grace 68 | "⏳" 69 | when :blocked 70 | "🚫" 71 | end 72 | end 73 | 74 | def to_h 75 | { 76 | state: @state, 77 | message: @message, 78 | limit_key: @limit_key, 79 | metadata: @metadata, 80 | ok: ok?, 81 | warning: warning?, 82 | grace: grace?, 83 | blocked: blocked? 84 | } 85 | end 86 | 87 | def inspect 88 | "#" 89 | end 90 | 91 | class << self 92 | def within(message = "Within limit", **options) 93 | new(state: :within, message: message, **options) 94 | end 95 | 96 | def warning(message, **options) 97 | new(state: :warning, message: message, **options) 98 | end 99 | 100 | def grace(message, **options) 101 | new(state: :grace, message: message, **options) 102 | end 103 | 104 | def blocked(message, **options) 105 | new(state: :blocked, message: message, **options) 106 | end 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /pricing_plans.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/pricing_plans/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "pricing_plans" 7 | spec.version = PricingPlans::VERSION 8 | spec.authors = ["rameerez"] 9 | spec.email = ["rubygems@rameerez.com"] 10 | 11 | spec.summary = "Define and enforce pricing plan limits (entitlements, quotas, feature gating) in your Rails SaaS" 12 | spec.description = "Define and enforce pricing plan limits in your Rails SaaS (entitlements, quotas, feature gating). pricing_plans acts as your single source of truth for pricing plans. Define a pricing catalog with feature gating, persistent caps, per‑period allowances, grace periods, and get view/controller/model helpers. Seamless Stripe/Pay ergonomics and UI‑agnostic helpers to build pricing tables, plan usage meters, plan limit alerts, upgrade prompts, and more." 13 | spec.homepage = "https://github.com/rameerez/pricing_plans" 14 | spec.license = "MIT" 15 | spec.required_ruby_version = ">= 3.2.0" 16 | 17 | spec.metadata["homepage_uri"] = spec.homepage 18 | spec.metadata["source_code_uri"] = "https://github.com/rameerez/pricing_plans" 19 | spec.metadata["changelog_uri"] = "https://github.com/rameerez/pricing_plans/blob/main/CHANGELOG.md" 20 | spec.metadata["bug_tracker_uri"] = "https://github.com/rameerez/pricing_plans/issues" 21 | spec.metadata["documentation_uri"] = "https://github.com/rameerez/pricing_plans#readme" 22 | spec.metadata["rubygems_mfa_required"] = "true" 23 | 24 | # Specify which files should be added to the gem when it is released. 25 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 26 | gemspec = File.basename(__FILE__) 27 | spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls| 28 | ls.readlines("\x0", chomp: true).reject do |f| 29 | (f == gemspec) || 30 | f.start_with?(*%w[bin/ test/ spec/ features/ .git appveyor Gemfile]) 31 | end 32 | end 33 | spec.bindir = "exe" 34 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 35 | spec.require_paths = ["lib"] 36 | 37 | # Core dependencies 38 | spec.add_dependency "activerecord", "~> 7.1", ">= 7.1.0" 39 | spec.add_dependency "activesupport", "~> 7.1", ">= 7.1.0" 40 | 41 | # Development dependencies 42 | spec.add_development_dependency "bundler", "~> 2.0" 43 | spec.add_development_dependency "rake", "~> 13.0" 44 | spec.add_development_dependency "minitest", "~> 5.0" 45 | spec.add_development_dependency "sqlite3", "~> 2.1" 46 | spec.add_development_dependency "rubocop", "~> 1.0" 47 | spec.add_development_dependency "rubocop-minitest", "~> 0.35" 48 | spec.add_development_dependency "rubocop-performance", "~> 1.0" 49 | end 50 | -------------------------------------------------------------------------------- /lib/pricing_plans/overage_reporter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PricingPlans 4 | class OverageReporter 5 | OverageItem = Struct.new( 6 | :limit_key, 7 | :kind, 8 | :current_usage, 9 | :allowed, 10 | :overage, 11 | :grace_active, 12 | :grace_ends_at, 13 | keyword_init: true 14 | ) 15 | 16 | Report = Struct.new(:items, :message, keyword_init: true) 17 | 18 | class << self 19 | # Compute overage against a target plan for the given plan_owner. 20 | # Returns an array of OverageItem for limits that are over the target. 21 | # kind: :persistent or :per_period 22 | def report(plan_owner, target_plan) 23 | plan = target_plan.is_a?(PricingPlans::Plan) ? target_plan : Registry.plan(target_plan.to_sym) 24 | 25 | plan.limits.map do |limit_key, limit_config| 26 | next if limit_config[:to] == :unlimited 27 | 28 | usage = LimitChecker.current_usage_for(plan_owner, limit_key, limit_config) 29 | allowed = limit_config[:to] 30 | over_by = [usage - allowed.to_i, 0].max 31 | next if over_by <= 0 32 | 33 | OverageItem.new( 34 | limit_key: limit_key, 35 | kind: (limit_config[:per] ? :per_period : :persistent), 36 | current_usage: usage, 37 | allowed: allowed, 38 | overage: over_by, 39 | grace_active: GraceManager.grace_active?(plan_owner, limit_key), 40 | grace_ends_at: GraceManager.grace_ends_at(plan_owner, limit_key) 41 | ) 42 | end.compact 43 | end 44 | 45 | # Returns a Report with items and a human message suitable for downgrade UX. 46 | def report_with_message(plan_owner, target_plan) 47 | items = report(plan_owner, target_plan) 48 | return Report.new(items: [], message: "No overages on target plan") if items.empty? 49 | 50 | parts = items.map do |i| 51 | "#{i.limit_key}: #{i.current_usage} > #{i.allowed} (reduce by #{i.overage})" 52 | end 53 | grace_info = items.select(&:grace_active).map do |i| 54 | ends = i.grace_ends_at&.utc&.iso8601 55 | "#{i.limit_key} grace ends at #{ends}" 56 | end 57 | 58 | msg = if PricingPlans.configuration&.message_builder 59 | begin 60 | built = PricingPlans.configuration.message_builder.call( 61 | context: :overage_report, 62 | items: items 63 | ) 64 | built if built 65 | rescue StandardError 66 | nil 67 | end 68 | end 69 | msg ||= "Over target plan on: #{parts.join(', ')}. " 70 | msg += "Grace active — #{grace_info.join(', ')}." unless grace_info.empty? 71 | 72 | Report.new(items: items, message: msg) 73 | end 74 | 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/pricing_plans/engine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PricingPlans 4 | class Engine < ::Rails::Engine 5 | isolate_namespace PricingPlans 6 | 7 | initializer "pricing_plans.active_record" do 8 | ActiveSupport.on_load(:active_record) do 9 | # Make models available 10 | require "pricing_plans/models/enforcement_state" 11 | require "pricing_plans/models/usage" 12 | require "pricing_plans/models/assignment" 13 | end 14 | end 15 | 16 | initializer "pricing_plans.action_controller" do 17 | ActiveSupport.on_load(:action_controller) do 18 | # Include controller guards in ApplicationController 19 | include PricingPlans::ControllerGuards 20 | # Install a sensible default rescue for feature gating so apps get 403 by default. 21 | # Apps can override by defining their own rescue_from in their controllers. 22 | include PricingPlans::ControllerRescues if defined?(PricingPlans::ControllerRescues) 23 | end 24 | end 25 | 26 | # Support API-only apps (ActionController::API) 27 | initializer "pricing_plans.action_controller_api" do 28 | ActiveSupport.on_load(:action_controller_api) do 29 | include PricingPlans::ControllerGuards 30 | include PricingPlans::ControllerRescues if defined?(PricingPlans::ControllerRescues) 31 | end 32 | end 33 | 34 | # Include view helpers (pure-data, no HTML opinions) 35 | initializer "pricing_plans.action_view" do 36 | ActiveSupport.on_load(:action_view) do 37 | include PricingPlans::ViewHelpers if defined?(PricingPlans::ViewHelpers) 38 | end 39 | end 40 | 41 | # Ensure the configured plan owner class (e.g., Organization) gains the 42 | # owner-centric helpers even if the model is not loaded during 43 | # configuration time. Runs on each code reload in dev. 44 | initializer "pricing_plans.plan_owner_helpers" do 45 | ActiveSupport::Reloader.to_prepare do 46 | begin 47 | klass = PricingPlans::Registry.plan_owner_class 48 | if klass && !klass.included_modules.include?(PricingPlans::PlanOwner) 49 | klass.include(PricingPlans::PlanOwner) 50 | end 51 | rescue StandardError 52 | # If the plan owner class isn't resolved yet, skip; next reload will try again. 53 | end 54 | end 55 | end 56 | 57 | # Add generator paths 58 | config.generators do |g| 59 | g.templates.unshift File.expand_path("../../generators", __dir__) 60 | end 61 | 62 | # Map FeatureDenied to HTTP 403 by default so unhandled exceptions don't become 500s. 63 | initializer "pricing_plans.rescue_responses" do |app| 64 | app.config.action_dispatch.rescue_responses.merge!( 65 | "PricingPlans::FeatureDenied" => :forbidden 66 | ) if app.config.respond_to?(:action_dispatch) && app.config.action_dispatch.respond_to?(:rescue_responses) 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/pricing_plans/plan_resolver.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PricingPlans 4 | class PlanResolver 5 | class << self 6 | def effective_plan_for(plan_owner) 7 | # 1. Check Pay subscription status first (no app-specific gate required) 8 | if PaySupport.pay_available? 9 | plan_from_pay = resolve_plan_from_pay(plan_owner) 10 | return plan_from_pay if plan_from_pay 11 | end 12 | 13 | # 2. Check manual assignment 14 | if plan_owner.respond_to?(:id) 15 | assignment = Assignment.find_by( 16 | plan_owner_type: plan_owner.class.name, 17 | plan_owner_id: plan_owner.id 18 | ) 19 | return Registry.plan(assignment.plan_key) if assignment 20 | end 21 | 22 | # 3. Fall back to default plan 23 | Registry.default_plan 24 | end 25 | 26 | def plan_key_for(plan_owner) 27 | effective_plan_for(plan_owner)&.key 28 | end 29 | 30 | def assign_plan_manually!(plan_owner, plan_key, source: "manual") 31 | Assignment.assign_plan_to(plan_owner, plan_key, source: source) 32 | end 33 | 34 | def remove_manual_assignment!(plan_owner) 35 | Assignment.remove_assignment_for(plan_owner) 36 | end 37 | 38 | private 39 | 40 | # Backward-compatible shim for tests that stub pay_available? 41 | def pay_available? 42 | PaySupport.pay_available? 43 | end 44 | 45 | def resolve_plan_from_pay(plan_owner) 46 | return nil unless plan_owner.respond_to?(:subscribed?) || 47 | plan_owner.respond_to?(:on_trial?) || 48 | plan_owner.respond_to?(:on_grace_period?) || 49 | plan_owner.respond_to?(:subscriptions) 50 | 51 | # Check if plan_owner has active subscription, trial, or grace period 52 | if PaySupport.subscription_active_for?(plan_owner) 53 | subscription = PaySupport.current_subscription_for(plan_owner) 54 | return nil unless subscription 55 | 56 | # Map processor plan to our plan 57 | processor_plan = subscription.processor_plan 58 | return plan_from_processor_plan(processor_plan) if processor_plan 59 | end 60 | 61 | nil 62 | end 63 | 64 | def plan_from_processor_plan(processor_plan) 65 | # Look through all plans to find one matching this Stripe price 66 | Registry.plans.values.find do |plan| 67 | stripe_price = plan.stripe_price 68 | next unless stripe_price 69 | 70 | case stripe_price 71 | when String 72 | stripe_price == processor_plan 73 | when Hash 74 | stripe_price[:id] == processor_plan || 75 | stripe_price[:month] == processor_plan || 76 | stripe_price[:year] == processor_plan || 77 | stripe_price.values.include?(processor_plan) 78 | else 79 | false 80 | end 81 | end 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /test/usage_status_helpers_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class UsageStatusHelpersTest < ActiveSupport::TestCase 6 | 7 | def setup 8 | super 9 | @org = create_organization 10 | end 11 | 12 | def test_status_returns_structs 13 | list = PricingPlans.status(@org, limits: [:projects, :custom_models, :activations]) 14 | assert list.is_a?(Array) 15 | item = list.first 16 | assert_respond_to item, :key 17 | assert_respond_to item, :current 18 | assert_respond_to item, :allowed 19 | assert_respond_to item, :percent_used 20 | end 21 | 22 | def test_severity_and_message_and_overage_helpers 23 | org = @org 24 | assert_equal :ok, PricingPlans.severity_for(org, :projects) 25 | assert_nil PricingPlans.message_for(org, :projects) 26 | assert_equal 0, PricingPlans.overage_for(org, :projects) 27 | 28 | # Simulate over limit (allowed=1, current=2) 29 | PricingPlans::LimitChecker.stub(:current_usage_for, 2) do 30 | assert_includes [:warning, :grace, :blocked], PricingPlans.severity_for(org, :projects) 31 | assert_kind_of String, PricingPlans.message_for(org, :projects) 32 | assert_equal 1, PricingPlans.overage_for(org, :projects) 33 | end 34 | end 35 | 36 | def test_attention_and_approaching_helpers 37 | org = @org 38 | refute PricingPlans.attention_required?(org, :projects) 39 | refute PricingPlans.approaching_limit?(org, :projects) 40 | 41 | # at 100% of 1 allowed 42 | PricingPlans::LimitChecker.stub(:plan_limit_percent_used, 100.0) do 43 | assert PricingPlans.attention_required?(org, :projects) 44 | assert PricingPlans.approaching_limit?(org, :projects) 45 | assert PricingPlans.approaching_limit?(org, :projects, at: 0.5) 46 | end 47 | end 48 | 49 | def test_cta_for_with_defaults 50 | org = @org 51 | PricingPlans.configuration.default_cta_text = "Upgrade" 52 | PricingPlans.configuration.default_cta_url = "/billing" 53 | 54 | data = PricingPlans.cta_for(org) 55 | assert_equal({ text: "Upgrade", url: "/billing" }, data) 56 | ensure 57 | PricingPlans.configuration.default_cta_text = nil 58 | PricingPlans.configuration.default_cta_url = nil 59 | end 60 | 61 | def test_cta_for_fallback_to_redirect_on_blocked_limit 62 | org = @org 63 | # Ensure no defaults 64 | PricingPlans.configuration.default_cta_text = nil 65 | PricingPlans.configuration.default_cta_url = nil 66 | PricingPlans.configuration.redirect_on_blocked_limit = "/pricing" 67 | 68 | data = PricingPlans.cta_for(org) 69 | assert_equal "/pricing", data[:url] 70 | ensure 71 | PricingPlans.configuration.redirect_on_blocked_limit = nil 72 | end 73 | 74 | def test_alert_for_view_model 75 | org = @org 76 | vm = PricingPlans.alert_for(org, :projects) 77 | assert_equal false, vm[:visible?] 78 | 79 | PricingPlans::LimitChecker.stub(:current_usage_for, 2) do 80 | vm = PricingPlans.alert_for(org, :projects) 81 | assert_equal true, vm[:visible?] 82 | assert_includes [:warning, :grace, :blocked, :at_limit], vm[:severity] 83 | assert_kind_of String, vm[:title] 84 | assert_includes vm.keys, :cta_text 85 | assert_includes vm.keys, :cta_url 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /.github/workflows/claude-code-review.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code Review 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize] 6 | # Optional: Only run on specific file changes 7 | # paths: 8 | # - "src/**/*.ts" 9 | # - "src/**/*.tsx" 10 | # - "src/**/*.js" 11 | # - "src/**/*.jsx" 12 | 13 | jobs: 14 | claude-review: 15 | # Optional: Filter by PR author 16 | # if: | 17 | # github.event.pull_request.user.login == 'external-contributor' || 18 | # github.event.pull_request.user.login == 'new-developer' || 19 | # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' 20 | 21 | runs-on: ubuntu-latest 22 | permissions: 23 | contents: read 24 | pull-requests: read 25 | issues: read 26 | id-token: write 27 | 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v4 31 | with: 32 | fetch-depth: 1 33 | 34 | - name: Run Claude Code Review 35 | id: claude-review 36 | uses: anthropics/claude-code-action@beta 37 | with: 38 | claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} 39 | 40 | # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1) 41 | # model: "claude-opus-4-1-20250805" 42 | 43 | # Direct prompt for automated review (no @claude mention needed) 44 | direct_prompt: | 45 | Please review this pull request and provide feedback on: 46 | - Code quality and best practices 47 | - Potential bugs or issues 48 | - Performance considerations 49 | - Security concerns 50 | - Test coverage 51 | 52 | Be constructive and helpful in your feedback. 53 | 54 | # Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR 55 | # use_sticky_comment: true 56 | 57 | # Optional: Customize review based on file types 58 | # direct_prompt: | 59 | # Review this PR focusing on: 60 | # - For TypeScript files: Type safety and proper interface usage 61 | # - For API endpoints: Security, input validation, and error handling 62 | # - For React components: Performance, accessibility, and best practices 63 | # - For tests: Coverage, edge cases, and test quality 64 | 65 | # Optional: Different prompts for different authors 66 | # direct_prompt: | 67 | # ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' && 68 | # 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' || 69 | # 'Please provide a thorough code review focusing on our coding standards and best practices.' }} 70 | 71 | # Optional: Add specific tools for running tests or linting 72 | # allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)" 73 | 74 | # Optional: Skip review for certain conditions 75 | # if: | 76 | # !contains(github.event.pull_request.title, '[skip-review]') && 77 | # !contains(github.event.pull_request.title, '[WIP]') 78 | 79 | -------------------------------------------------------------------------------- /test/complex_associations_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class ComplexAssociationsTest < ActiveSupport::TestCase 6 | def setup 7 | super 8 | PricingPlans.reset_configuration! 9 | PricingPlans.configure do |config| 10 | config.default_plan = :free 11 | config.plan :free do 12 | limits :ai_models, to: 1 13 | limits :deeply_nested_resources, to: 2 14 | end 15 | end 16 | end 17 | 18 | def test_custom_limit_key_and_foreign_key 19 | # Define child with different class name and table but using same projects table for reuse 20 | Object.const_set(:CustomAiModel, Class.new(ActiveRecord::Base)) 21 | CustomAiModel.class_eval do 22 | self.table_name = "projects" 23 | belongs_to :company, class_name: "Organization", foreign_key: "organization_id" 24 | end 25 | 26 | # Extend Organization with non-standard association 27 | Organization.class_eval do 28 | include PricingPlans::PlanOwner unless included_modules.include?(PricingPlans::PlanOwner) 29 | has_many :ai_models, 30 | class_name: "CustomAiModel", 31 | foreign_key: "organization_id", 32 | limited_by_pricing_plans: { limit_key: :ai_models, error_after_limit: "Too many AI models!" } 33 | end 34 | 35 | org = Organization.create!(name: "ACME") 36 | CustomAiModel.create!(name: "M1", company: org) 37 | 38 | # English sugar generated 39 | assert_respond_to org, :ai_models_within_plan_limits? 40 | assert_respond_to org, :ai_models_remaining 41 | assert_equal false, org.ai_models_within_plan_limits?(by: 1) 42 | assert_includes CustomAiModel.pricing_plans_limits.keys, :ai_models 43 | assert_equal :company, CustomAiModel.pricing_plans_limits[:ai_models][:plan_owner_method] 44 | ensure 45 | Object.send(:remove_const, :CustomAiModel) if defined?(CustomAiModel) 46 | end 47 | 48 | def test_deeply_nested_association_and_sugar 49 | # Simulate a namespaced model name 50 | Object.const_set(:Deeply, Module.new) unless defined?(Deeply) 51 | Deeply.const_set(:NestedResource, Class.new(ActiveRecord::Base)) 52 | Deeply::NestedResource.class_eval do 53 | self.table_name = "projects" 54 | belongs_to :organization 55 | end 56 | 57 | Organization.class_eval do 58 | include PricingPlans::PlanOwner unless included_modules.include?(PricingPlans::PlanOwner) 59 | has_many :deeply_nested_resources, 60 | class_name: "Deeply::NestedResource", 61 | limited_by_pricing_plans: true 62 | end 63 | 64 | org = Organization.create!(name: "Deep Org") 65 | Deeply::NestedResource.create!(name: "R1", organization: org) 66 | Deeply::NestedResource.create!(name: "R2", organization: org) 67 | 68 | # English sugar for plural, limit is 2 → next should be blocked 69 | assert_respond_to org, :deeply_nested_resources_within_plan_limits? 70 | assert_equal false, org.deeply_nested_resources_within_plan_limits?(by: 1) 71 | assert :deeply_nested_resources, Deeply::NestedResource.pricing_plans_limits.keys 72 | assert_equal :organization, Deeply::NestedResource.pricing_plans_limits[:deeply_nested_resources][:plan_owner_method] 73 | ensure 74 | Deeply.send(:remove_const, :NestedResource) if defined?(Deeply::NestedResource) 75 | Object.send(:remove_const, :Deeply) if defined?(Deeply) 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/generators/pricing_plans/install/templates/create_pricing_plans_tables.rb.erb: -------------------------------------------------------------------------------- 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 | 92 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require: 4 | - rubocop-minitest 5 | - rubocop-performance 6 | 7 | AllCops: 8 | TargetRubyVersion: 3.2 9 | NewCops: enable 10 | Exclude: 11 | - 'bin/**/*' 12 | - 'vendor/**/*' 13 | - 'test/dummy/**/*' 14 | - 'db/migrate/**/*' # Generated migrations 15 | - 'lib/generators/**/templates/**/*' # Generator templates 16 | 17 | # Layout & Formatting 18 | Layout/LineLength: 19 | Max: 120 20 | AllowedPatterns: 21 | - '\s*#.*' # Allow long comments 22 | - '^\s*raise\s' # Allow long raise statements 23 | 24 | Layout/MultilineMethodCallIndentation: 25 | EnforcedStyle: indented 26 | 27 | Layout/ArgumentAlignment: 28 | EnforcedStyle: with_first_argument 29 | 30 | Layout/FirstArgumentIndentation: 31 | EnforcedStyle: consistent 32 | 33 | # Style 34 | Style/Documentation: 35 | Enabled: false # Don't require class documentation for now 36 | 37 | Style/StringLiterals: 38 | EnforcedStyle: double_quotes 39 | 40 | Style/FrozenStringLiteralComment: 41 | Enabled: true 42 | EnforcedStyle: always 43 | 44 | Style/ClassAndModuleChildren: 45 | EnforcedStyle: compact 46 | 47 | Style/GuardClause: 48 | MinBodyLength: 3 49 | 50 | # Metrics 51 | Metrics/ClassLength: 52 | Max: 150 53 | 54 | Metrics/ModuleLength: 55 | Max: 150 56 | 57 | Metrics/MethodLength: 58 | Max: 25 59 | AllowedMethods: 60 | - 'configure' # Configuration blocks can be longer 61 | 62 | Metrics/BlockLength: 63 | Max: 25 64 | AllowedMethods: 65 | - 'configure' 66 | - 'describe' 67 | - 'context' 68 | - 'it' 69 | - 'test' 70 | Exclude: 71 | - 'test/**/*' # Allow long test blocks 72 | 73 | Metrics/AbcSize: 74 | Max: 20 75 | AllowedMethods: 76 | - 'configure' 77 | 78 | Metrics/CyclomaticComplexity: 79 | Max: 8 80 | 81 | Metrics/PerceivedComplexity: 82 | Max: 10 83 | 84 | # Naming 85 | Naming/PredicateName: 86 | ForbiddenPrefixes: 87 | - 'is_' 88 | AllowedMethods: 89 | - 'is_a?' 90 | 91 | # Performance 92 | Performance/StringReplacement: 93 | Enabled: true 94 | 95 | Performance/RedundantMerge: 96 | Enabled: true 97 | 98 | # Minitest 99 | Minitest/MultipleAssertions: 100 | Enabled: false # Allow multiple assertions in integration tests 101 | 102 | Minitest/AssertTruthy: 103 | Enabled: false # Allow assert instead of assert_equal true 104 | 105 | # Rails-specific (even though we don't have full Rails) 106 | Style/Rails/HttpStatus: 107 | Enabled: false 108 | 109 | # Custom overrides for this gem 110 | Style/AccessorGrouping: 111 | Enabled: false # Allow separate attr_reader/attr_writer 112 | 113 | Style/MutableConstant: 114 | Enabled: false # We have some intentionally mutable constants 115 | 116 | # Allow class variables for registry pattern 117 | Style/ClassVars: 118 | Enabled: false 119 | 120 | # Allow metaprogramming patterns common in Rails engines 121 | Style/EvalWithLocation: 122 | Enabled: false 123 | 124 | Lint/MissingSuper: 125 | Enabled: false # Allow classes that don't call super 126 | 127 | # Disable some cops that don't work well with our DSL 128 | Style/MethodCallWithoutArgsParentheses: 129 | Enabled: false # Our DSL looks better without parens 130 | 131 | # Thread safety 132 | Style/GlobalVars: 133 | AllowedVariables: ['$0'] # Only allow program name 134 | 135 | # Database-related 136 | Style/NumericLiterals: 137 | Enabled: false # Allow raw numbers in database IDs/amounts -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | pricing_plans (0.1.0) 5 | activerecord (~> 7.1, >= 7.1.0) 6 | activesupport (~> 7.1, >= 7.1.0) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | activemodel (7.2.2.2) 12 | activesupport (= 7.2.2.2) 13 | activerecord (7.2.2.2) 14 | activemodel (= 7.2.2.2) 15 | activesupport (= 7.2.2.2) 16 | timeout (>= 0.4.0) 17 | activesupport (7.2.2.2) 18 | base64 19 | benchmark (>= 0.3) 20 | bigdecimal 21 | concurrent-ruby (~> 1.0, >= 1.3.1) 22 | connection_pool (>= 2.2.5) 23 | drb 24 | i18n (>= 1.6, < 2) 25 | logger (>= 1.4.2) 26 | minitest (>= 5.1) 27 | securerandom (>= 0.3) 28 | tzinfo (~> 2.0, >= 2.0.5) 29 | ast (2.4.3) 30 | base64 (0.3.0) 31 | benchmark (0.4.1) 32 | bigdecimal (3.2.2) 33 | concurrent-ruby (1.3.5) 34 | connection_pool (2.5.3) 35 | date (3.4.1) 36 | docile (1.4.1) 37 | drb (2.2.3) 38 | erb (5.0.2) 39 | i18n (1.14.7) 40 | concurrent-ruby (~> 1.0) 41 | io-console (0.8.1) 42 | irb (1.15.2) 43 | pp (>= 0.6.0) 44 | rdoc (>= 4.0.0) 45 | reline (>= 0.4.2) 46 | json (2.13.2) 47 | language_server-protocol (3.17.0.5) 48 | lint_roller (1.1.0) 49 | logger (1.7.0) 50 | minitest (5.25.5) 51 | ostruct (0.6.3) 52 | parallel (1.27.0) 53 | parser (3.3.9.0) 54 | ast (~> 2.4.1) 55 | racc 56 | pp (0.6.2) 57 | prettyprint 58 | prettyprint (0.2.0) 59 | prism (1.4.0) 60 | psych (5.2.6) 61 | date 62 | stringio 63 | racc (1.8.1) 64 | rainbow (3.1.1) 65 | rake (13.3.0) 66 | rdoc (6.14.2) 67 | erb 68 | psych (>= 4.0.0) 69 | regexp_parser (2.11.0) 70 | reline (0.6.2) 71 | io-console (~> 0.5) 72 | rubocop (1.79.2) 73 | json (~> 2.3) 74 | language_server-protocol (~> 3.17.0.2) 75 | lint_roller (~> 1.1.0) 76 | parallel (~> 1.10) 77 | parser (>= 3.3.0.2) 78 | rainbow (>= 2.2.2, < 4.0) 79 | regexp_parser (>= 2.9.3, < 3.0) 80 | rubocop-ast (>= 1.46.0, < 2.0) 81 | ruby-progressbar (~> 1.7) 82 | unicode-display_width (>= 2.4.0, < 4.0) 83 | rubocop-ast (1.46.0) 84 | parser (>= 3.3.7.2) 85 | prism (~> 1.4) 86 | rubocop-minitest (0.38.1) 87 | lint_roller (~> 1.1) 88 | rubocop (>= 1.75.0, < 2.0) 89 | rubocop-ast (>= 1.38.0, < 2.0) 90 | rubocop-performance (1.25.0) 91 | lint_roller (~> 1.1) 92 | rubocop (>= 1.75.0, < 2.0) 93 | rubocop-ast (>= 1.38.0, < 2.0) 94 | ruby-progressbar (1.13.0) 95 | securerandom (0.4.1) 96 | simplecov (0.22.0) 97 | docile (~> 1.1) 98 | simplecov-html (~> 0.11) 99 | simplecov_json_formatter (~> 0.1) 100 | simplecov-html (0.13.2) 101 | simplecov_json_formatter (0.1.4) 102 | sqlite3 (2.7.3-arm64-darwin) 103 | sqlite3 (2.7.3-x86_64-linux-gnu) 104 | stringio (3.1.7) 105 | timeout (0.4.3) 106 | tzinfo (2.0.6) 107 | concurrent-ruby (~> 1.0) 108 | unicode-display_width (3.1.4) 109 | unicode-emoji (~> 4.0, >= 4.0.4) 110 | unicode-emoji (4.0.4) 111 | 112 | PLATFORMS 113 | arm64-darwin-24 114 | x86_64-linux-gnu 115 | 116 | DEPENDENCIES 117 | bundler (~> 2.0) 118 | irb 119 | minitest (~> 5.0) 120 | ostruct 121 | pricing_plans! 122 | rake (~> 13.0) 123 | rubocop (~> 1.0) 124 | rubocop-minitest (~> 0.35) 125 | rubocop-performance (~> 1.0) 126 | simplecov 127 | sqlite3 (~> 2.1) 128 | 129 | BUNDLED WITH 130 | 2.6.9 131 | -------------------------------------------------------------------------------- /test/integer_refinements_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | # Test class that uses refinement at the top level 6 | class TestWithRefinement 7 | using PricingPlans::IntegerRefinements 8 | 9 | def self.test_max_method(number) 10 | number.max 11 | end 12 | 13 | def self.test_time_methods 14 | if 1.respond_to?(:days) 15 | # ActiveSupport is loaded, use its methods 16 | { 17 | one_day: 1.day, 18 | seven_days: 7.days, 19 | one_week: 1.week, 20 | two_weeks: 2.weeks 21 | } 22 | else 23 | # Use our fallback implementations 24 | { 25 | one_day: 1.day, 26 | seven_days: 7.days, 27 | one_week: 1.week, 28 | two_weeks: 2.weeks, 29 | one_month: 1.month, 30 | two_months: 2.months 31 | } 32 | end 33 | end 34 | end 35 | 36 | class IntegerRefinementsTest < ActiveSupport::TestCase 37 | def test_max_refinement 38 | assert_equal 5, TestWithRefinement.test_max_method(5) 39 | assert_equal 10, TestWithRefinement.test_max_method(10) 40 | assert_equal(-3, TestWithRefinement.test_max_method(-3)) 41 | end 42 | 43 | def test_time_method_availability 44 | time_values = TestWithRefinement.test_time_methods 45 | 46 | if defined?(ActiveSupport) 47 | # ActiveSupport is loaded, so we get its duration objects 48 | assert_respond_to time_values[:one_day], :seconds 49 | assert_respond_to time_values[:seven_days], :seconds 50 | assert_respond_to time_values[:one_week], :seconds 51 | assert_respond_to time_values[:two_weeks], :seconds 52 | else 53 | # Our fallback implementations return raw seconds 54 | assert_equal 86400, time_values[:one_day] 55 | assert_equal 604800, time_values[:seven_days] # 7 * 86400 56 | assert_equal 604800, time_values[:one_week] 57 | assert_equal 1209600, time_values[:two_weeks] # 14 * 86400 58 | assert_equal 2592000, time_values[:one_month] # 30 * 86400 59 | assert_equal 5184000, time_values[:two_months] # 60 * 86400 60 | end 61 | end 62 | 63 | def test_refinement_scoped_only_to_using_context 64 | # Outside the refinement scope, max method shouldn't be available on Integer 65 | # unless it's defined by something else 66 | if 5.respond_to?(:max) 67 | skip "Integer#max is available outside refinement scope (possibly from ActiveSupport or other gem)" 68 | else 69 | assert_raises(NoMethodError) do 70 | 5.max 71 | end 72 | end 73 | end 74 | 75 | def test_refinement_provides_max_identity_function 76 | # Test that our refinement provides the .max method that returns self 77 | result_5 = TestWithRefinement.test_max_method(5) 78 | result_100 = TestWithRefinement.test_max_method(100) 79 | result_0 = TestWithRefinement.test_max_method(0) 80 | 81 | assert_equal 5, result_5 82 | assert_equal 100, result_100 83 | assert_equal 0, result_0 84 | end 85 | 86 | def test_refinement_module_exists 87 | assert defined?(PricingPlans::IntegerRefinements) 88 | assert PricingPlans::IntegerRefinements.is_a?(Module) 89 | end 90 | 91 | def test_refinement_provides_dsl_sugar 92 | # The refinement is used in Plan class to provide DSL sugar like `5.max` 93 | # This is tested via the TestWithRefinement class above 94 | limit_value = TestWithRefinement.test_max_method(42) 95 | assert_equal 42, limit_value 96 | 97 | # The refinement enables expressive DSL like `limits :projects, to: 5.max` 98 | # where 5.max is just syntactic sugar that returns 5 99 | assert_equal 5, TestWithRefinement.test_max_method(5) 100 | end 101 | end -------------------------------------------------------------------------------- /lib/pricing_plans/pay_support.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PricingPlans 4 | module PaySupport 5 | module_function 6 | 7 | def pay_available? 8 | defined?(Pay) 9 | end 10 | 11 | def subscription_active_for?(plan_owner) 12 | return false unless plan_owner 13 | 14 | # Prefer Pay's official API on the payment_processor 15 | if plan_owner.respond_to?(:payment_processor) && (pp = plan_owner.payment_processor) 16 | return true if (pp.respond_to?(:subscribed?) && pp.subscribed?) || 17 | (pp.respond_to?(:on_trial?) && pp.on_trial?) || 18 | (pp.respond_to?(:on_grace_period?) && pp.on_grace_period?) 19 | 20 | if pp.respond_to?(:subscriptions) && (subs = pp.subscriptions) 21 | return subs.any? { |sub| (sub.respond_to?(:active?) && sub.active?) || (sub.respond_to?(:on_trial?) && sub.on_trial?) || (sub.respond_to?(:on_grace_period?) && sub.on_grace_period?) } 22 | end 23 | end 24 | 25 | # Fallbacks for apps that surface Pay state on the owner 26 | individual_active = (plan_owner.respond_to?(:subscribed?) && plan_owner.subscribed?) || 27 | (plan_owner.respond_to?(:on_trial?) && plan_owner.on_trial?) || 28 | (plan_owner.respond_to?(:on_grace_period?) && plan_owner.on_grace_period?) 29 | return true if individual_active 30 | 31 | if plan_owner.respond_to?(:subscriptions) && (subs = plan_owner.subscriptions) 32 | return subs.any? { |sub| (sub.respond_to?(:active?) && sub.active?) || (sub.respond_to?(:on_trial?) && sub.on_trial?) || (sub.respond_to?(:on_grace_period?) && sub.on_grace_period?) } 33 | end 34 | 35 | false 36 | end 37 | 38 | def current_subscription_for(plan_owner) 39 | return nil unless pay_available? 40 | 41 | # Prefer Pay's payment_processor API 42 | if plan_owner.respond_to?(:payment_processor) && (pp = plan_owner.payment_processor) 43 | if pp.respond_to?(:subscription) 44 | subscription = pp.subscription 45 | if subscription && ( 46 | (subscription.respond_to?(:active?) && subscription.active?) || 47 | (subscription.respond_to?(:on_trial?) && subscription.on_trial?) || 48 | (subscription.respond_to?(:on_grace_period?) && subscription.on_grace_period?) 49 | ) 50 | return subscription 51 | end 52 | end 53 | 54 | if pp.respond_to?(:subscriptions) && (subs = pp.subscriptions) 55 | found = subs.find do |sub| 56 | (sub.respond_to?(:on_trial?) && sub.on_trial?) || 57 | (sub.respond_to?(:on_grace_period?) && sub.on_grace_period?) || 58 | (sub.respond_to?(:active?) && sub.active?) 59 | end 60 | return found if found 61 | end 62 | end 63 | 64 | # Fallbacks for apps that surface subscriptions on the owner 65 | if plan_owner.respond_to?(:subscription) 66 | subscription = plan_owner.subscription 67 | if subscription && ( 68 | (subscription.respond_to?(:active?) && subscription.active?) || 69 | (subscription.respond_to?(:on_trial?) && subscription.on_trial?) || 70 | (subscription.respond_to?(:on_grace_period?) && subscription.on_grace_period?) 71 | ) 72 | return subscription 73 | end 74 | end 75 | 76 | if plan_owner.respond_to?(:subscriptions) && (subs = plan_owner.subscriptions) 77 | subs.find do |sub| 78 | (sub.respond_to?(:on_trial?) && sub.on_trial?) || 79 | (sub.respond_to?(:on_grace_period?) && sub.on_grace_period?) || 80 | (sub.respond_to?(:active?) && sub.active?) 81 | end 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/generators/pricing_plans/install/templates/initializer.rb: -------------------------------------------------------------------------------- 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 101 | -------------------------------------------------------------------------------- /test/controller_rescues_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class ControllerRescuesTest < ActiveSupport::TestCase 6 | class FlashDouble 7 | def initialize 8 | @store = {} 9 | @now = {} 10 | end 11 | def []=(k, v) 12 | @store[k] = v 13 | end 14 | def [](k) 15 | @store[k] 16 | end 17 | def now 18 | @now 19 | end 20 | end 21 | 22 | class DummyRequest 23 | attr_reader :format 24 | 25 | def initialize(html: false, json: false) 26 | @format = DummyFormat.new(html: html, json: json) 27 | end 28 | 29 | class DummyFormat 30 | def initialize(html:, json:) 31 | @html = html 32 | @json = json 33 | end 34 | 35 | def html? 36 | @html 37 | end 38 | 39 | def json? 40 | @json 41 | end 42 | end 43 | end 44 | 45 | class DummyController 46 | include PricingPlans::ControllerRescues 47 | 48 | attr_reader :performed_action 49 | 50 | def initialize(request:, provide_pricing_path: true) 51 | @request = request 52 | @provide_pricing_path = provide_pricing_path 53 | @performed_action = nil 54 | # Define pricing_path helper only when requested 55 | if @provide_pricing_path 56 | define_singleton_method(:pricing_path) { "/pricing" } 57 | end 58 | end 59 | 60 | def request 61 | @request 62 | end 63 | 64 | def redirect_to(path, **kwargs) 65 | @performed_action = { action: :redirect_to, path: path, kwargs: kwargs, flash: flash } 66 | end 67 | 68 | def render(**kwargs) 69 | @performed_action = { action: :render, kwargs: kwargs, flash: flash } 70 | end 71 | 72 | def head(status) 73 | @performed_action = { action: :head, status: status } 74 | end 75 | 76 | def flash 77 | @flash ||= FlashDouble.new 78 | end 79 | 80 | # Expose the private handler for testing 81 | def handle!(error) 82 | send(:handle_pricing_plans_feature_denied, error) 83 | end 84 | end 85 | 86 | def test_html_redirects_to_pricing_path_with_message 87 | controller = DummyController.new(request: DummyRequest.new(html: true), provide_pricing_path: true) 88 | error = PricingPlans::FeatureDenied.new("Upgrade to Pro to access Api access") 89 | 90 | controller.handle!(error) 91 | 92 | action = controller.performed_action 93 | assert_equal :redirect_to, action[:action] 94 | assert_equal "/pricing", action[:path] 95 | assert_equal :see_other, action[:kwargs][:status] 96 | # Handler surfaces the provided message in flash 97 | assert_match(/upgrade to pro/i, action[:flash][:alert]) 98 | end 99 | 100 | def test_html_without_pricing_path_renders_forbidden_plain 101 | controller = DummyController.new(request: DummyRequest.new(html: true), provide_pricing_path: false) 102 | error = PricingPlans::FeatureDenied.new("Feature not available") 103 | 104 | controller.handle!(error) 105 | 106 | action = controller.performed_action 107 | assert_equal :render, action[:action] 108 | assert_equal :forbidden, action[:kwargs][:status] 109 | assert_equal "Feature not available", action[:kwargs][:plain] 110 | assert_match(/feature not available/i, action[:flash].now[:alert].to_s) 111 | end 112 | 113 | def test_json_renders_error_payload_with_403 114 | controller = DummyController.new(request: DummyRequest.new(json: true)) 115 | error = PricingPlans::FeatureDenied.new("Access denied") 116 | 117 | controller.handle!(error) 118 | 119 | action = controller.performed_action 120 | assert_equal :render, action[:action] 121 | assert_equal :forbidden, action[:kwargs][:status] 122 | assert_equal({ error: "Access denied" }, action[:kwargs][:json].slice(:error)) 123 | end 124 | 125 | def test_fallback_renders_json_403_when_format_unknown 126 | # Neither html nor json → default to json 403 127 | controller = DummyController.new(request: DummyRequest.new) 128 | error = PricingPlans::FeatureDenied.new("Denied") 129 | 130 | controller.handle!(error) 131 | 132 | action = controller.performed_action 133 | assert_equal :render, action[:action] 134 | assert_equal :forbidden, action[:kwargs][:status] 135 | assert_equal({ error: "Denied" }, action[:kwargs][:json]) 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /test/plan_pricing_api_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class PlanPricingApiTest < ActiveSupport::TestCase 6 | def setup 7 | super 8 | PricingPlans.reset_configuration! 9 | end 10 | 11 | def with_stripe_stub(month_amount_cents: 1500, year_amount_cents: 18000, currency: "usd", month_id: "price_m", year_id: "price_y") 12 | stripe_mod = Module.new 13 | mmc = month_amount_cents 14 | ymc = year_amount_cents 15 | curr = currency 16 | mid = month_id 17 | yid = year_id 18 | price_class = Class.new do 19 | define_singleton_method(:retrieve) do |id| 20 | if id == mid 21 | recurring = Struct.new(:interval).new("month") 22 | Struct.new(:unit_amount, :recurring, :currency).new(mmc, recurring, curr) 23 | else 24 | recurring = Struct.new(:interval).new("year") 25 | Struct.new(:unit_amount, :recurring, :currency).new(ymc, recurring, curr) 26 | end 27 | end 28 | end 29 | stripe_mod.const_set(:Price, price_class) 30 | Object.const_set(:Stripe, stripe_mod) 31 | yield 32 | ensure 33 | Object.send(:remove_const, :Stripe) if defined?(Stripe) 34 | end 35 | 36 | def test_has_interval_prices_with_numeric_and_string 37 | PricingPlans.configure do |config| 38 | config.default_plan = :basic 39 | config.plan :basic do 40 | price 10 41 | end 42 | end 43 | plan = PricingPlans::Registry.plan(:basic) 44 | assert plan.has_interval_prices? 45 | assert plan.has_numeric_price? 46 | 47 | PricingPlans.reset_configuration! 48 | PricingPlans.configure do |config| 49 | config.default_plan = :ent 50 | config.plan :ent do 51 | price_string "Contact" 52 | end 53 | end 54 | plan2 = PricingPlans::Registry.plan(:ent) 55 | assert plan2.has_interval_prices? 56 | refute plan2.has_numeric_price? 57 | end 58 | 59 | def test_has_interval_prices_with_stripe_month_year 60 | PricingPlans.configure do |config| 61 | config.default_plan = :pro 62 | config.plan :pro do 63 | stripe_price month: "price_m", year: "price_y" 64 | end 65 | end 66 | assert PricingPlans::Registry.plan(:pro).has_interval_prices? 67 | end 68 | 69 | def test_label_for_month_and_year_from_stripe 70 | PricingPlans.configure do |config| 71 | config.default_plan = :pro 72 | config.plan :pro do 73 | stripe_price month: "price_m", year: "price_y" 74 | end 75 | end 76 | plan = PricingPlans::Registry.plan(:pro) 77 | with_stripe_stub(month_amount_cents: 1500, year_amount_cents: 18000) do 78 | assert_match(/\$15\/mo/, plan.price_label_for(:month)) 79 | assert_match(/\$180\/yr/, plan.price_label_for(:year)) 80 | end 81 | end 82 | 83 | def test_monthly_and_yearly_price_cents_and_ids 84 | PricingPlans.configure do |config| 85 | config.default_plan = :pro 86 | config.plan :pro do 87 | stripe_price month: "price_m", year: "price_y" 88 | end 89 | end 90 | plan = PricingPlans::Registry.plan(:pro) 91 | with_stripe_stub(month_amount_cents: 990, year_amount_cents: 9990) do 92 | assert_equal 990, plan.monthly_price_cents 93 | assert_equal 9990, plan.yearly_price_cents 94 | assert_equal "price_m", plan.monthly_price_id 95 | assert_equal "price_y", plan.yearly_price_id 96 | end 97 | end 98 | 99 | def test_to_view_model_contains_expected_keys 100 | PricingPlans.configure do |config| 101 | config.default_plan = :free 102 | config.plan :free do 103 | price 0 104 | limits :projects, to: 3 105 | end 106 | end 107 | vm = PricingPlans::Registry.plan(:free).to_view_model 108 | assert_equal %i[id key name description features highlighted default free currency monthly_price_cents yearly_price_cents monthly_price_id yearly_price_id price_label price_string limits].sort, vm.keys.sort 109 | end 110 | 111 | def test_plan_comparison_and_downgrade_policy 112 | PricingPlans.configure do |config| 113 | config.default_plan = :basic 114 | config.plan :basic do 115 | price 10 116 | end 117 | config.plan :pro do 118 | price 20 119 | end 120 | config.downgrade_policy = ->(from:, to:, plan_owner:) { to.price.to_i < from.price.to_i ? [false, "Not allowed"] : [true, nil] } 121 | end 122 | basic = PricingPlans::Registry.plan(:basic) 123 | pro = PricingPlans::Registry.plan(:pro) 124 | assert pro.upgrade_from?(basic) 125 | assert basic.downgrade_from?(pro) 126 | assert_equal "Not allowed", basic.downgrade_blocked_reason(from: pro) 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /test/price_components_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class PriceComponentsTest < ActiveSupport::TestCase 6 | def setup 7 | super 8 | PricingPlans.reset_configuration! 9 | end 10 | 11 | def configure_with_numeric_price 12 | PricingPlans.configure do |config| 13 | config.default_plan = :pro 14 | config.plan :pro do 15 | price 29 16 | end 17 | end 18 | end 19 | 20 | def configure_with_price_string 21 | PricingPlans.configure do |config| 22 | config.default_plan = :enterprise 23 | config.plan :enterprise do 24 | price_string "Contact us" 25 | end 26 | end 27 | end 28 | 29 | def configure_with_stripe_ids 30 | PricingPlans.configure do |config| 31 | config.default_plan = :pro 32 | config.plan :pro do 33 | stripe_price month: "price_month_123", year: "price_year_456" 34 | end 35 | end 36 | end 37 | 38 | def with_stripe_stub(month_amount_cents: 2900, year_amount_cents: 29900, currency: "usd") 39 | stripe_mod = Module.new 40 | mmc = month_amount_cents 41 | ymc = year_amount_cents 42 | curr = currency 43 | price_class = Class.new do 44 | define_singleton_method(:retrieve) do |id| 45 | case id 46 | when "price_month_123" 47 | recurring = Struct.new(:interval).new("month") 48 | Struct.new(:unit_amount, :recurring, :currency).new(mmc, recurring, curr) 49 | when "price_year_456" 50 | recurring = Struct.new(:interval).new("year") 51 | Struct.new(:unit_amount, :recurring, :currency).new(ymc, recurring, curr) 52 | else 53 | recurring = Struct.new(:interval).new("month") 54 | Struct.new(:unit_amount, :recurring, :currency).new(mmc, recurring, curr) 55 | end 56 | end 57 | end 58 | stripe_mod.const_set(:Price, price_class) 59 | Object.const_set(:Stripe, stripe_mod) 60 | yield 61 | ensure 62 | Object.send(:remove_const, :Stripe) if defined?(Stripe) 63 | end 64 | 65 | def test_price_components_for_numeric_price_month 66 | configure_with_numeric_price 67 | plan = PricingPlans::Registry.plan(:pro) 68 | pc = plan.price_components(interval: :month) 69 | assert_equal true, pc.present? 70 | assert_equal "$", pc.currency 71 | assert_equal "29", pc.amount 72 | assert_equal 2900, pc.amount_cents 73 | assert_equal :month, pc.interval 74 | assert_match(/\$29\/mo/, pc.label) 75 | assert_equal 2900, pc.monthly_equivalent_cents 76 | end 77 | 78 | def test_price_components_for_numeric_price_year 79 | configure_with_numeric_price 80 | plan = PricingPlans::Registry.plan(:pro) 81 | pc = plan.price_components(interval: :year) 82 | assert_equal true, pc.present? 83 | assert_equal :year, pc.interval 84 | assert_match(/\/yr\z/, pc.label) 85 | end 86 | 87 | def test_price_components_for_price_string 88 | configure_with_price_string 89 | plan = PricingPlans::Registry.plan(:enterprise) 90 | pc = plan.price_components(interval: :month) 91 | refute pc.present? 92 | assert_equal "Contact us", pc.label 93 | assert_nil pc.amount 94 | assert_nil pc.amount_cents 95 | end 96 | 97 | def test_price_components_from_stripe 98 | configure_with_stripe_ids 99 | plan = PricingPlans::Registry.plan(:pro) 100 | with_stripe_stub do 101 | pcm = plan.monthly_price_components 102 | pcy = plan.yearly_price_components 103 | assert_equal 2900, pcm.amount_cents 104 | assert_equal 29900, pcy.amount_cents 105 | assert_equal "$", plan.currency_symbol 106 | end 107 | end 108 | 109 | def test_currency_symbol_mapping_eur 110 | configure_with_stripe_ids 111 | plan = PricingPlans::Registry.plan(:pro) 112 | with_stripe_stub(month_amount_cents: 1000, year_amount_cents: 12000, currency: "eur") do 113 | assert_equal "€", plan.currency_symbol 114 | end 115 | end 116 | 117 | class MemoryCache 118 | attr_reader :writes 119 | def initialize 120 | @store = {} 121 | @writes = [] 122 | end 123 | def read(key) 124 | @store[key] 125 | end 126 | def write(key, value, **_opts) 127 | @writes << key 128 | @store[key] = value 129 | end 130 | end 131 | 132 | def test_stripe_lookup_uses_cache_when_available 133 | cache = MemoryCache.new 134 | configure_with_stripe_ids 135 | PricingPlans.configuration.price_cache = cache 136 | plan = PricingPlans::Registry.plan(:pro) 137 | with_stripe_stub do 138 | plan.monthly_price_components 139 | assert cache.writes.any? 140 | end 141 | ensure 142 | PricingPlans.configuration.price_cache = nil 143 | end 144 | end 145 | 146 | 147 | -------------------------------------------------------------------------------- /test/limitable_inference_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class LimitableInferenceTest < ActiveSupport::TestCase 6 | def test_infers_limit_key_when_omitted 7 | test_class = Class.new(ActiveRecord::Base) do 8 | self.table_name = "projects" 9 | belongs_to :organization 10 | include PricingPlans::Limitable 11 | limited_by_pricing_plans # infer :projects and plan_owner :organization 12 | end 13 | 14 | limits = test_class.pricing_plans_limits 15 | assert limits.key?(:projects) 16 | assert_equal :organization, limits[:projects][:plan_owner_method] 17 | assert_nil limits[:projects][:per] 18 | end 19 | 20 | def test_falls_back_to_self_when_no_association_found 21 | test_class = Class.new(ActiveRecord::Base) do 22 | self.table_name = "organizations" 23 | include PricingPlans::Limitable 24 | limited_by_pricing_plans :own_records 25 | end 26 | 27 | limits = test_class.pricing_plans_limits 28 | assert_equal :self, limits[:own_records][:plan_owner_method] 29 | end 30 | 31 | def test_on_alias_for_plan_owner_keyword 32 | test_class = Class.new(ActiveRecord::Base) do 33 | self.table_name = "projects" 34 | belongs_to :organization 35 | include PricingPlans::Limitable 36 | limited_by_pricing_plans :projects, on: :organization 37 | end 38 | 39 | limits = test_class.pricing_plans_limits 40 | assert_equal :organization, limits[:projects][:plan_owner_method] 41 | end 42 | 43 | def test_plan_owner_declares_association_limits_via_has_many_option 44 | klass_name = "OrgWithLimitedAssoc_#{SecureRandom.hex(4)}" 45 | Object.const_set(klass_name, Class.new(ActiveRecord::Base)) 46 | org_class = Object.const_get(klass_name).class_eval do 47 | self.table_name = "organizations" 48 | include PricingPlans::PlanOwner 49 | has_many :projects, limited_by_pricing_plans: { error_after_limit: "Too many projects!" } 50 | self 51 | end 52 | 53 | # The child model should have received Limitable configuration for :projects 54 | assert Project.pricing_plans_limits.key?(:projects) 55 | assert_equal :organization, Project.pricing_plans_limits[:projects][:plan_owner_method] 56 | assert_equal "Too many projects!", Project.pricing_plans_limits[:projects][:error_after_limit] 57 | 58 | # The plan_owner should expose sugar methods 59 | inst = org_class.new 60 | assert_respond_to inst, :projects_within_plan_limits? 61 | assert_respond_to inst, :projects_remaining 62 | ensure 63 | Object.send(:remove_const, klass_name.to_sym) if Object.const_defined?(klass_name.to_sym) 64 | end 65 | 66 | def test_has_many_limited_registers_when_child_class_defined_later 67 | plan_owner_const = "OrgWithPending_#{SecureRandom.hex(4)}" 68 | child_const = "LaterProject_#{SecureRandom.hex(4)}" 69 | Object.const_set(plan_owner_const, Class.new(ActiveRecord::Base)) 70 | org_class = Object.const_get(plan_owner_const).class_eval do 71 | self.table_name = "organizations" 72 | include PricingPlans::PlanOwner 73 | has_many :later_projects, 74 | class_name: child_const, 75 | foreign_key: "organization_id", 76 | limited_by_pricing_plans: { per: :month, error_after_limit: "Monthly cap" } 77 | self 78 | end 79 | 80 | # Child not defined yet, so pending registry should have captured it 81 | assert PricingPlans::AssociationLimitRegistry.pending.any? 82 | 83 | # Define the child class mapping to existing projects table 84 | Object.const_set(child_const, Class.new(ActiveRecord::Base)) 85 | Object.const_get(child_const).class_eval do 86 | self.table_name = "projects" 87 | belongs_to :organization 88 | end 89 | 90 | # Flush pending to complete wiring 91 | PricingPlans::AssociationLimitRegistry.flush_pending! 92 | 93 | child_klass = Object.const_get(child_const) 94 | limits = child_klass.pricing_plans_limits 95 | assert limits.key?(:later_projects) 96 | assert_equal :organization, limits[:later_projects][:plan_owner_method] 97 | assert_equal :month, limits[:later_projects][:per] 98 | assert_equal "Monthly cap", limits[:later_projects][:error_after_limit] 99 | ensure 100 | Object.send(:remove_const, plan_owner_const.to_sym) if Object.const_defined?(plan_owner_const.to_sym) 101 | Object.send(:remove_const, child_const.to_sym) if Object.const_defined?(child_const.to_sym) 102 | end 103 | 104 | def test_registers_counter_only_for_persistent_caps 105 | klass = Class.new(ActiveRecord::Base) do 106 | self.table_name = "projects" 107 | belongs_to :organization 108 | include PricingPlans::Limitable 109 | limited_by_pricing_plans :projects 110 | limited_by_pricing_plans :custom_models, per: :month 111 | end 112 | 113 | assert PricingPlans::LimitableRegistry.counter_for(:projects) 114 | assert_nil PricingPlans::LimitableRegistry.counter_for(:custom_models) 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /lib/pricing_plans/controller_rescues.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PricingPlans 4 | # Default controller-level rescues for a great out-of-the-box DX. 5 | # Included automatically by the engine into ActionController::Base and ActionController::API. 6 | # Applications can override by defining their own rescue_from handlers. 7 | module ControllerRescues 8 | def self.included(base) 9 | # Install a default mapping for FeatureDenied → 403 with helpful messaging. 10 | if base.respond_to?(:rescue_from) 11 | base.rescue_from(PricingPlans::FeatureDenied) do |error| 12 | handle_pricing_plans_feature_denied(error) 13 | end 14 | end 15 | end 16 | 17 | private 18 | 19 | # Default behavior tries to respond appropriately for HTML and JSON. 20 | # - HTML/Turbo: set a flash alert with an idiomatic message and redirect to pricing if available; otherwise render 403 with flash.now 21 | # - JSON: 403 with structured error { error, feature, plan } 22 | # Apps can override by defining this method in their own ApplicationController. 23 | def handle_pricing_plans_feature_denied(error) 24 | if html_request? 25 | # Prefer redirect + flash for idiomatic Rails UX when we have a pricing_path 26 | if respond_to?(:pricing_path) 27 | flash[:alert] = error.message 28 | redirect_to(pricing_path, status: :see_other) 29 | else 30 | # No pricing route helper; render with 403 and show inline flash 31 | flash.now[:alert] = error.message if respond_to?(:flash) && flash.respond_to?(:now) 32 | respond_to?(:render) ? render(status: :forbidden, plain: error.message) : head(:forbidden) 33 | end 34 | elsif json_request? 35 | payload = { 36 | error: error.message, 37 | feature: (error.respond_to?(:feature_key) ? error.feature_key : nil), 38 | plan: begin 39 | if error.respond_to?(:plan_owner) 40 | plan_obj = PricingPlans::PlanResolver.effective_plan_for(error.plan_owner) 41 | elsif error.respond_to?(:plan_owner) 42 | plan_obj = PricingPlans::PlanResolver.effective_plan_for(error.plan_owner) 43 | else 44 | plan_obj = nil 45 | end 46 | plan_obj&.name 47 | rescue StandardError 48 | nil 49 | end 50 | }.compact 51 | render(json: payload, status: :forbidden) 52 | else 53 | # API or miscellaneous formats 54 | if respond_to?(:render) 55 | render(json: { error: error.message }, status: :forbidden) 56 | else 57 | head :forbidden if respond_to?(:head) 58 | end 59 | end 60 | end 61 | 62 | # Centralized handler for plan limit blocks. Apps can override this method 63 | # in their own ApplicationController to customize redirects/flash. 64 | # Receives the PricingPlans::Result for the blocked check. 65 | def handle_pricing_plans_limit_blocked(result) 66 | message = result&.message || "Plan limit reached" 67 | redirect_target = (result&.metadata || {})[:redirect_to] 68 | 69 | if html_request? 70 | # Prefer explicit/derived redirect target if provided by the guard 71 | if redirect_target 72 | flash[:alert] = message if respond_to?(:flash) 73 | redirect_to(redirect_target, status: :see_other) if respond_to?(:redirect_to) 74 | elsif respond_to?(:pricing_path) 75 | flash[:alert] = message if respond_to?(:flash) 76 | redirect_to(pricing_path, status: :see_other) if respond_to?(:redirect_to) 77 | else 78 | flash.now[:alert] = message if respond_to?(:flash) && flash.respond_to?(:now) 79 | render(status: :forbidden, plain: message) if respond_to?(:render) 80 | end 81 | elsif json_request? 82 | payload = { 83 | error: message, 84 | limit: result&.limit_key, 85 | plan: begin 86 | plan_obj = PricingPlans::PlanResolver.effective_plan_for(result&.plan_owner) 87 | plan_obj&.name 88 | rescue StandardError 89 | nil 90 | end 91 | }.compact 92 | render(json: payload, status: :forbidden) if respond_to?(:render) 93 | else 94 | render(json: { error: message }, status: :forbidden) if respond_to?(:render) 95 | head :forbidden if respond_to?(:head) 96 | end 97 | end 98 | 99 | def html_request? 100 | return false unless respond_to?(:request) 101 | req = request 102 | req && req.respond_to?(:format) && req.format.respond_to?(:html?) && req.format.html? 103 | rescue StandardError 104 | false 105 | end 106 | 107 | def json_request? 108 | return false unless respond_to?(:request) 109 | req = request 110 | req && req.respond_to?(:format) && req.format.respond_to?(:json?) && req.format.json? 111 | rescue StandardError 112 | false 113 | end 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /test/plan_owner_helpers_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class PlanOwnerHelpersTest < ActiveSupport::TestCase 6 | def setup 7 | super 8 | PricingPlans.reset_configuration! 9 | PricingPlans.configure do |config| 10 | config.default_plan = :free 11 | config.plan :free do 12 | limits :projects, to: 1 13 | end 14 | end 15 | # Re-register counters after reset 16 | Project.send(:limited_by_pricing_plans, :projects, plan_owner: :organization) if Project.respond_to?(:limited_by_pricing_plans) 17 | end 18 | 19 | def test_auto_includes_helpers_into_configured_plan_owner 20 | org = Organization.new(name: "Acme") 21 | 22 | assert_respond_to org, :within_plan_limits? 23 | assert_respond_to org, :plan_limit_remaining 24 | assert_respond_to org, :plan_limit_percent_used 25 | assert_respond_to org, :current_pricing_plan 26 | assert_respond_to org, :assign_pricing_plan! 27 | assert_respond_to org, :remove_pricing_plan! 28 | assert_respond_to org, :plan_allows? 29 | assert_respond_to org, :pay_subscription_active? 30 | assert_respond_to org, :pay_on_trial? 31 | assert_respond_to org, :pay_on_grace_period? 32 | assert_respond_to org, :grace_active_for? 33 | assert_respond_to org, :grace_ends_at_for 34 | assert_respond_to org, :grace_remaining_seconds_for 35 | assert_respond_to org, :grace_remaining_days_for 36 | assert_respond_to org, :plan_blocked_for? 37 | 38 | # Smoke check a real call path 39 | assert_equal :free, org.current_pricing_plan.key 40 | end 41 | 42 | def test_englishy_sugar_methods_defined_from_associations 43 | # Organization has has_many :projects in test schema via Project model 44 | # Simulate declaration from plan_owner side to define sugar methods 45 | unless Organization.method_defined?(:projects_within_plan_limits?) 46 | PricingPlans::PlanOwner.define_limit_sugar_methods(Organization, :projects) 47 | end 48 | 49 | org = create_organization 50 | assert_respond_to org, :projects_within_plan_limits? 51 | assert_respond_to org, :projects_remaining 52 | assert_respond_to org, :projects_percent_used 53 | assert_respond_to org, :projects_grace_active? 54 | assert_respond_to org, :projects_grace_ends_at 55 | assert_respond_to org, :projects_blocked? 56 | 57 | # Smoke-check calls 58 | org.projects.create!(name: "P1") 59 | assert_equal false, org.projects_within_plan_limits?(by: 1) 60 | assert (org.projects_remaining.is_a?(Integer) || org.projects_remaining == :unlimited) 61 | assert org.projects_percent_used.is_a?(Numeric) 62 | end 63 | 64 | def test_feature_sugar_plan_allows_dynamic 65 | org = Organization.create!(name: "Acme") 66 | # Default plan does not allow :api_access 67 | refute org.plan_allows_api_access? 68 | 69 | # Add a pro plan that allows the feature and assign it 70 | PricingPlans.configure do |config| 71 | config.plan :pro do 72 | allows :api_access 73 | end 74 | end 75 | PricingPlans::Assignment.assign_plan_to(org, :pro) 76 | assert org.plan_allows_api_access? 77 | assert_equal org.plan_allows?(:api_access), org.plan_allows_api_access? 78 | end 79 | 80 | def test_feature_sugar_respond_to_missing 81 | org = Organization.create!(name: "Acme") 82 | assert org.respond_to?(:plan_allows_api_access?) 83 | # Pattern-based predicate methods should be discoverable 84 | assert org.respond_to?(:plan_allows_completely_made_up_feature?) 85 | # Unknown features simply return false 86 | refute org.plan_allows_completely_made_up_feature? 87 | end 88 | 89 | def test_idempotent_inclusion_on_reconfigure 90 | org = Organization.new(name: "Acme") 91 | assert_respond_to org, :within_plan_limits? 92 | 93 | # Reconfigure shouldn't break inclusion or duplicate 94 | PricingPlans.reset_configuration! 95 | PricingPlans.configure do |config| 96 | config.default_plan = :free 97 | config.plan :free do 98 | limits :projects, to: 1 99 | end 100 | end 101 | 102 | org2 = Organization.new(name: "Beta") 103 | assert_respond_to org2, :within_plan_limits? 104 | end 105 | 106 | def test_attach_helpers_when_plan_owner_defined_after_config 107 | # Configure with a class name that does not exist yet 108 | PricingPlans.reset_configuration! 109 | PricingPlans.configure do |config| 110 | config.plan_owner_class = "LatePlanOwner" 111 | config.default_plan = :free 112 | config.plan :free do 113 | limits :projects, to: 1 114 | end 115 | end 116 | 117 | # Define the class afterwards 118 | Object.const_set(:LatePlanOwner, Class.new) 119 | 120 | # Simulate engine's to_prepare hook by invoking the attachment helper 121 | PricingPlans::Registry.send(:attach_plan_owner_helpers!) 122 | 123 | late = LatePlanOwner.new 124 | assert_respond_to late, :within_plan_limits? 125 | assert_respond_to late, :current_pricing_plan 126 | ensure 127 | Object.send(:remove_const, :LatePlanOwner) if defined?(LatePlanOwner) 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /docs/04-views.md: -------------------------------------------------------------------------------- 1 | # Views: pricing pages, paywalls, usage indicators, conditional UI 2 | 3 | Since `pricing_plans` is your single source of truth for pricing plans, you can query it at any time and get easy-to-display information to create views like pricing pages and paywalls very easily. 4 | 5 | `pricing_plans` is UI-agnostic, meaning we don't ship any UI components with the gem, but we provide you with all the data you need to build UI components easily. You fully control the HTML/CSS, while `pricing_plans` gives you clear, composable data. 6 | 7 | ## Display all plans 8 | 9 | `PricingPlans.plans` returns an array of `PricingPlans::Plan` objects containing all your plans defined in `pricing_plans.rb` 10 | 11 | Each `PricingPlans::Plan` responds to: 12 | - `plan.free?` 13 | - `plan.highlighted?` 14 | - `plan.popular?` (alias of `highlighted?`) 15 | - `plan.name` 16 | - `plan.description` 17 | - `plan.bullets` → Array of strings 18 | - `plan.price_label` → The `price` or `price_string` you've defined for the plan. If `stripe_price` is set and the Stripe gem is available, it auto-fetches the live price from Stripe. You can override or disable this. 19 | - `plan.cta_text` 20 | - `plan.cta_url` 21 | 22 | ### Example: build a pricing page 23 | 24 | Building a pricing table is as easy as iterating over all `Plans` and displaying their info: 25 | 26 | ```erb 27 | <% PricingPlans.plans.each do |plan| %> 28 |
29 |

<%= plan.name %>

30 |

<%= plan.description %>

31 | 36 |
<%= plan.price_label %>
37 | <% if (url = plan.cta_url) %> 38 | <%= link_to plan.cta_text, url, class: 'btn' %> 39 | <% else %> 40 | <%= button_tag plan.cta_text, class: 'btn', disabled: true %> 41 | <% end %> 42 |
43 | <% end %> 44 | ``` 45 | 46 | > [!TIP] 47 | > If you need more detail for the price (not just `price_label`, but also if it's monthly, yearly, etc.) check out the [Semantic Pricing API](/docs/05-semantic-pricing.md). 48 | 49 | 50 | ![pricing_plans Ruby on Rails gem - pricing table features](/docs/images/pricing_plans_ruby_rails_gem_pricing_table.jpg) 51 | 52 | ## Get the highlighted plan 53 | 54 | You get helpers to access the highlighted plan: 55 | - `PricingPlans.highlighted_plan` 56 | - `PricingPlans.highlighted_plan_key` 57 | 58 | 59 | ## Get the next plan suggestion 60 | - `PricingPlans.suggest_next_plan_for(plan_owner, keys: [:projects, ...])` 61 | 62 | 63 | ## Conditional UI 64 | 65 | ![pricing_plans Ruby gem - conditional UI](/docs/images/product_creation_blocked.jpg) 66 | 67 | We can leverage the [model methods and helpers](/docs/03-model-helpers.md) to build conditional UIs depending on pricing plan limits: 68 | 69 | ### Example: disable buttons when outside plan limits 70 | 71 | You can gate object creation by enabling or disabling create buttons depending on limits usage: 72 | 73 | ```erb 74 | <% if current_organization.within_plan_limits?(:projects) %> 75 | 76 | <% else %> 77 | 78 | <% end %> 79 | ``` 80 | 81 | Tip: you could also use `plan_allows?(:api_access)` to build feature-gating UIs. 82 | 83 | ### Example: block an entire element if not in plan 84 | 85 | ```erb 86 | <% if current_organization.plan_blocked_for?(:projects) %> 87 | 88 | <% end %> 89 | ``` 90 | 91 | ## Alerts and usage 92 | 93 | ![pricing_plans Ruby on Rails gem - pricing plan upgrade prompt](/docs/images/pricing_plans_ruby_rails_gem_usage_alert_upgrade.jpg) 94 | 95 | ### Example: display an alert for a limit 96 | 97 | ```erb 98 | <% if current_organization.attention_required_for_limit?(:projects) %> 99 | <%= render "shared/plan_limit_alert", plan_owner: current_organization, key: :projects %> 100 | <% end %> 101 | ``` 102 | 103 | ### Example: plan usage summary 104 | 105 | ```erb 106 | <% s = current_organization.limit(:projects) %> 107 |
<%= s.key.to_s.humanize %>: <%= s.current %> / <%= s.allowed %> (<%= s.percent_used.round(1) %>%)
108 | <% if s.blocked %> 109 |
Creation blocked due to plan limits
110 | <% elsif s.grace_active %> 111 |
Over limit — grace active until <%= s.grace_ends_at %>
112 | <% end %> 113 | ``` 114 | 115 | Tip: you could also use `plan_limit_remaining(:projects)` and `plan_limit_percent_used(:projects)` to show current usage. 116 | 117 | ![pricing_plans Ruby on Rails gem - pricing plan usage meter](/docs/images/pricing_plans_ruby_rails_gem_usage_meter.jpg) 118 | 119 | ## Message customization 120 | 121 | - You can override copy globally via `config.message_builder` in [`pricing_plans.rb`](/docs/01-define-pricing-plans.md), which is used across limit checks and features. Suggested signature: `(context:, **kwargs) -> string` with contexts `:over_limit`, `:grace`, `:feature_denied`, and `:overage_report`. -------------------------------------------------------------------------------- /lib/pricing_plans/limit_checker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PricingPlans 4 | class LimitChecker 5 | class << self 6 | # English-y aliases used widely across helpers/tests 7 | def plan_limit_remaining(plan_owner, limit_key) 8 | remaining(plan_owner, limit_key) 9 | end 10 | 11 | def plan_limit_percent_used(plan_owner, limit_key) 12 | percent_used(plan_owner, limit_key) 13 | end 14 | def within_limit?(plan_owner, limit_key, by: 1) 15 | remaining_amount = remaining(plan_owner, limit_key) 16 | return true if remaining_amount == :unlimited 17 | remaining_amount >= by 18 | end 19 | 20 | def remaining(plan_owner, limit_key) 21 | plan = PlanResolver.effective_plan_for(plan_owner) 22 | limit_config = plan&.limit_for(limit_key) 23 | return :unlimited unless limit_config 24 | 25 | limit_amount = limit_config[:to] 26 | return :unlimited if limit_amount == :unlimited 27 | 28 | current_usage = current_usage_for(plan_owner, limit_key, limit_config) 29 | [0, limit_amount - current_usage].max 30 | end 31 | 32 | def percent_used(plan_owner, limit_key) 33 | plan = PlanResolver.effective_plan_for(plan_owner) 34 | limit_config = plan&.limit_for(limit_key) 35 | return 0.0 unless limit_config 36 | 37 | limit_amount = limit_config[:to] 38 | return 0.0 if limit_amount == :unlimited || limit_amount.zero? 39 | 40 | current_usage = current_usage_for(plan_owner, limit_key, limit_config) 41 | [(current_usage.to_f / limit_amount) * 100, 100.0].min 42 | end 43 | 44 | # Keep short helpers undocumented; public API is plan_limit_* aliases 45 | 46 | def after_limit_action(plan_owner, limit_key) 47 | plan = PlanResolver.effective_plan_for(plan_owner) 48 | limit_config = plan&.limit_for(limit_key) 49 | return :block_usage unless limit_config 50 | 51 | limit_config[:after_limit] 52 | end 53 | 54 | def limit_amount(plan_owner, limit_key) 55 | plan = PlanResolver.effective_plan_for(plan_owner) 56 | limit_config = plan&.limit_for(limit_key) 57 | return :unlimited unless limit_config 58 | 59 | limit_config[:to] 60 | end 61 | 62 | def current_usage_for(plan_owner, limit_key, limit_config = nil) 63 | limit_config ||= begin 64 | plan = PlanResolver.effective_plan_for(plan_owner) 65 | plan&.limit_for(limit_key) 66 | end 67 | 68 | return 0 unless limit_config 69 | 70 | if limit_config[:per] 71 | # Per-period allowance - check usage table 72 | per_period_usage(plan_owner, limit_key) 73 | else 74 | # Persistent cap - count live objects 75 | persistent_usage(plan_owner, limit_key) 76 | end 77 | end 78 | 79 | def warning_thresholds(plan_owner, limit_key) 80 | plan = PlanResolver.effective_plan_for(plan_owner) 81 | limit_config = plan&.limit_for(limit_key) 82 | return [] unless limit_config 83 | 84 | limit_config[:warn_at] || [] 85 | end 86 | 87 | def should_warn?(plan_owner, limit_key) 88 | percent = percent_used(plan_owner, limit_key) 89 | thresholds = warning_thresholds(plan_owner, limit_key) 90 | 91 | # Find the highest threshold that has been crossed 92 | crossed_threshold = thresholds.select { |t| percent >= (t * 100) }.max 93 | return nil unless crossed_threshold 94 | 95 | # Check if we've already warned for this threshold 96 | state = enforcement_state(plan_owner, limit_key) 97 | last_threshold = state&.last_warning_threshold 98 | 99 | # Return the threshold if this is a new higher threshold, nil otherwise 100 | crossed_threshold > (last_threshold || 0) ? crossed_threshold : nil 101 | end 102 | 103 | private 104 | 105 | def per_period_usage(plan_owner, limit_key) 106 | period_start, period_end = PeriodCalculator.window_for(plan_owner, limit_key) 107 | 108 | usage = Usage.find_by( 109 | plan_owner: plan_owner, 110 | limit_key: limit_key.to_s, 111 | period_start: period_start, 112 | period_end: period_end 113 | ) 114 | 115 | usage&.used || 0 116 | end 117 | 118 | def persistent_usage(plan_owner, limit_key) 119 | # This is provided by the Limitable mixin, which registers per-model counters 120 | # keyed by limit key. When declared via has_many limited_by_pricing_plans, the 121 | # child model registers the counter as well. 122 | counter = LimitableRegistry.counter_for(limit_key) 123 | return 0 unless counter 124 | 125 | counter.call(plan_owner) 126 | end 127 | 128 | def enforcement_state(plan_owner, limit_key) 129 | EnforcementState.find_by( 130 | plan_owner: plan_owner, 131 | limit_key: limit_key.to_s 132 | ) 133 | end 134 | end 135 | end 136 | 137 | # Registry for Limitable counters 138 | class LimitableRegistry 139 | class << self 140 | def register_counter(limit_key, &block) 141 | counters[limit_key.to_sym] = block 142 | end 143 | 144 | def counter_for(limit_key) 145 | counters[limit_key.to_sym] 146 | end 147 | 148 | def counters 149 | @counters ||= {} 150 | end 151 | 152 | def clear! 153 | @counters = {} 154 | end 155 | end 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /docs/06-gem-compatibility.md: -------------------------------------------------------------------------------- 1 | # Using `pricing_plans` with `pay` and/or `usage_credits` 2 | 3 | `pricing_plans` is designed to work seamlessly with other complementary popular gems like `pay` (to handle actual subscriptions and payments), and `usage_credits` (to handle credit-like spending and refills) 4 | 5 | These gems are related but not overlapping. They're complementary. The boundaries are clear: billing is handled in Pay; metering (ledger-like) in usage_credits. 6 | 7 | The integration with `pay` should be seamless and is documented throughout the entire docs; however, here's a brief note about using `usage_credits` alongside `pricing_plans`. 8 | 9 | ## Using `pricing_plans` with the `usage_credits` gem 10 | 11 | In the SaaS world, pricing plans and usage credits are related in so far credits are usually a part of a pricing plan. A plan would give you, say, 100 credits a month along other features, and users would find that information usually documented in the pricing table itself. 12 | 13 | However, for the purposes of this gem, pricing plans and usage credits are two very distinct things. 14 | 15 | If you want to add credits to your app, you should install and configure the [usage_credits](https://github.com/rameerez/usage_credits) gem separately. In the `usage_credits` configuration, you should specify how many credits your users get with each subscription. 16 | 17 | ### The difference between usage credits and per-period plan limits 18 | 19 | > [!WARNING] 20 | > Usage credits are not the same as per-period limits. 21 | 22 | **Usage credits behave like a currency**. Per-period limits are not a currency, and shouldn't be purchaseable. 23 | 24 | - **Usage credits** are like: "100 image-generation credits a month" 25 | - **Per-period limits** are like: "Create up to 3 new projects a month" 26 | 27 | Usage credits can be refilled (buy credit packs, your balance goes up), can be spent (your balance goes down). Per-period limits do not. If you intend to sell credit packs, or if the balance needs to go both up and down, you should implement usage credits, NOT per-period limits. 28 | 29 | Some other examples of per-period limits: “1 domain change per week”, “2 exports/day”. Those are discrete allowances, not metered workloads. For classic metered workloads (API calls, image generations, tokenized compute), use credits instead. 30 | 31 | Here's a few rules for a clean separation to help you decide when to use either gem: 32 | 33 | `pricing_plans` handles: 34 | - Booleans (feature flags). 35 | - Persistent caps (max concurrent resources: products, seats, projects at a time). 36 | - Discrete per-period allowances (e.g., “3 exports / month”), with no overage purchasing. 37 | 38 | `usage_credits` handles: 39 | - Metered consumption (API calls, generations, storage GB*hrs, etc.). 40 | - Included monthly credits via subscription plans. 41 | - Top-ups and pay-as-you-go. 42 | - Rollover/expire semantics and the entire ledger. 43 | 44 | If a dimension is metered and you want to sell overage/top-ups, use credits only. Don’t also define a periodic limit for the same dimension in `pricing_plans`. We’ll actively lint and refuse dual definitions at boot. 45 | 46 | ### How to show `usage_credits` in `pricing_plans` 47 | 48 | With all that being said, in SaaS users would typically find information about plan credits in the pricing plan table, and because of that, and since `pricing_plans` should be the single source of truth for pricing plans in your Rails app, you should include how many credits your plans give in `pricing_plans.rb`: 49 | 50 | ```ruby 51 | PricingPlans.configure do |config| 52 | plan :pro do 53 | bullets "API access", "100 credits per month" 54 | end 55 | end 56 | ``` 57 | 58 | `pricing_plans` ships some ergonomics to declare and render included credits, and guardrails to keep your configuration coherent when `usage_credits` is present. 59 | 60 | #### Declare included credits in your plans (single currency) 61 | 62 | Plans can advertise the total credits included. This is cosmetic for pricing UI; `usage_credits` remains the source of truth for fulfillment and spending: 63 | 64 | ```ruby 65 | PricingPlans.configure do |config| 66 | config.plan :free do 67 | price 0 68 | includes_credits 100 69 | end 70 | 71 | config.plan :pro do 72 | price 29 73 | includes_credits 5_000 74 | end 75 | end 76 | ``` 77 | 78 | When you’re composing your UI, you can read credits via `plan.credits_included`. 79 | 80 | > [!IMPORTANT] 81 | > You need to keep defining operations and subscription fulfillment in your `usage_credits` initializer, declaring it in pricing_plans is purely cosmetic and for ergonomics to render pricing tables. 82 | 83 | #### Guardrails when `usage_credits` is installed 84 | 85 | When the `usage_credits` gem is present, we lint your configuration at boot to prevent ambiguous setups: 86 | 87 | Collisions between credits and per‑period plan limits are disallowed: you cannot define a per‑period limit for a key that is also a `usage_credits` operation (e.g., `limits :api_calls, to: 50, per: :month`). If a dimension is metered, use credits only. 88 | 89 | This enforces a clean separation: 90 | 91 | - Use `usage_credits` for metered workloads you may wish to top‑up or charge PAYG for. 92 | - Use `pricing_plans` limits for discrete allowances and feature flags (things that don’t behave like a currency). 93 | 94 | #### No runtime coupling; single source of truth 95 | 96 | `pricing_plans` does not spend or refill credits — that’s owned by `usage_credits`. 97 | 98 | - Keep using `@user.spend_credits_on(:operation, ...)`, subscription fulfillment, and credit packs in `usage_credits`. 99 | - Treat `includes_credits` here as pricing UI copy only. The single source of truth for operations, costs, fulfillment cadence, rollover/expire, and balances lives in `usage_credits`. 100 | -------------------------------------------------------------------------------- /docs/05-semantic-pricing.md: -------------------------------------------------------------------------------- 1 | # Semantic pricing 2 | 3 | Building delightful pricing UIs usually needs structured price parts (currency, amount, interval) and both monthly/yearly data. `pricing_plans` ships a semantic, UI‑agnostic API so you don't have to parse price strings in your app. 4 | 5 | ## Value object: `PricingPlans::PriceComponents` 6 | 7 | Structure returned by the helpers below: 8 | 9 | ```ruby 10 | PricingPlans::PriceComponents = Struct.new( 11 | :present?, # boolean: price is numeric? 12 | :currency, # string currency symbol, e.g. "$", "€" 13 | :amount, # string whole amount, e.g. "29" 14 | :amount_cents, # integer cents, e.g. 2900 15 | :interval, # :month | :year 16 | :label, # friendly label, e.g. "$29/mo" or "Contact" 17 | :monthly_equivalent_cents, # integer; = amount for monthly, or yearly/12 rounded 18 | keyword_init: true 19 | ) 20 | ``` 21 | 22 | ## Semantic pricing helpers 23 | 24 | ```ruby 25 | plan.price_components(interval: :month) # => PriceComponents 26 | plan.monthly_price_components # sugar for :month 27 | plan.yearly_price_components # sugar for :year 28 | 29 | plan.has_interval_prices? # true if configured/inferred 30 | plan.has_numeric_price? # true if numeric (price or stripe_price) 31 | 32 | plan.price_label_for(:month) # "$29/mo" (uses PriceComponents) 33 | plan.price_label_for(:year) # "$290/yr" or Stripe-derived 34 | 35 | plan.monthly_price_cents # integer or nil 36 | plan.yearly_price_cents # integer or nil 37 | plan.monthly_price_id # Stripe Price ID (when available) 38 | plan.yearly_price_id 39 | plan.currency_symbol # "$" or derived from Stripe 40 | ``` 41 | 42 | Notes: 43 | 44 | - If `stripe_price` is configured, we derive cents, currency, and interval from the Stripe Price (and cache it). 45 | - If `price 0` (free), we return components with `present? == true`, amount 0 and the configured default currency symbol. 46 | - If only `price_string` is set (e.g., "Contact us"), components return `present? == false`, `label == price_string`. 47 | 48 | ## Pure-data view models 49 | 50 | - Per‑plan: 51 | 52 | ```ruby 53 | plan.to_view_model 54 | # => { 55 | # id:, key:, name:, description:, features:, highlighted:, default:, free:, 56 | # currency:, monthly_price_cents:, yearly_price_cents:, 57 | # monthly_price_id:, yearly_price_id:, 58 | # price_label:, price_string:, limits: { ... } 59 | # } 60 | ``` 61 | 62 | - All plans (preserves `PricingPlans.plans` order): 63 | 64 | ```ruby 65 | PricingPlans.view_models # => Array 66 | ``` 67 | 68 | ## UI helpers 69 | 70 | We include data‑only helpers into ActionView. 71 | 72 | ```ruby 73 | pricing_plan_ui_data(plan) 74 | # => { 75 | # monthly_price:, yearly_price:, 76 | # monthly_price_cents:, yearly_price_cents:, 77 | # monthly_price_id:, yearly_price_id:, 78 | # free:, label: 79 | # } 80 | 81 | pricing_plan_cta(plan, plan_owner: nil, context: :marketing, current_plan: nil) 82 | # => { text:, url:, method: :get, disabled:, reason: } 83 | ``` 84 | 85 | `pricing_plan_cta` disables the button for the current plan (text: "Current Plan"). You can add a downgrade policy (see configuration) to surface `reason` in your UI. 86 | 87 | ## Plan comparison ergonomics (for CTAs) 88 | 89 | ```ruby 90 | plan.current_for?(current_plan) # boolean 91 | plan.upgrade_from?(current_plan) # boolean 92 | plan.downgrade_from?(current_plan) # boolean 93 | plan.downgrade_blocked_reason(from: current_plan, plan_owner: org) # string | nil 94 | ``` 95 | 96 | ## Stripe lookups and caching 97 | 98 | - We fetch Stripe Price objects when `stripe_price` is present. 99 | - Caching is supported via `config.price_cache` (defaults to `Rails.cache` when available). 100 | - TTL controlled by `config.price_cache_ttl` (default 10 minutes). 101 | 102 | Example initializer snippet: 103 | 104 | ```ruby 105 | PricingPlans.configure do |config| 106 | config.price_cache = Rails.cache 107 | config.price_cache_ttl = 10.minutes 108 | end 109 | ``` 110 | 111 | ## Configuration for pricing semantics 112 | 113 | ```ruby 114 | PricingPlans.configure do |config| 115 | # Currency symbol when Stripe is absent 116 | config.default_currency_symbol = "$" 117 | 118 | # Cache & TTL for Stripe Price lookups 119 | config.price_cache = Rails.cache 120 | config.price_cache_ttl = 10.minutes 121 | 122 | # Optional hook to fully customize components 123 | # Signature: ->(plan, interval) { PricingPlans::PriceComponents | nil } 124 | config.price_components_resolver = ->(plan, interval) { nil } 125 | 126 | # Optional free copy used by some data helpers 127 | config.free_price_caption = "Forever free" 128 | 129 | # Default UI interval for toggles 130 | config.interval_default_for_ui = :month # or :year 131 | 132 | # Downgrade policy used by CTA ergonomics 133 | # Signature: ->(from:, to:, plan_owner:) { [allowed_boolean, reason_or_nil] } 134 | config.downgrade_policy = ->(from:, to:, plan_owner:) { [true, nil] } 135 | end 136 | ``` 137 | 138 | ## Stripe price labels in `plan.price_label` 139 | 140 | By default, if a plan has `stripe_price` configured and the `stripe` gem is present, we auto-fetch the Stripe Price and render a friendly label (e.g., `$29/mo`). This mirrors Pay’s use of Stripe Prices. 141 | 142 | 143 | To disable auto-fetching globally: 144 | 145 | ```ruby 146 | PricingPlans.configure do |config| 147 | config.auto_price_labels_from_processor = false 148 | end 149 | ``` 150 | 151 | To fully customize rendering (e.g., caching, locale): 152 | 153 | ```ruby 154 | PricingPlans.configure do |config| 155 | config.price_label_resolver = ->(plan) do 156 | # Build and return a string like "$29/mo" based on your own logic 157 | end 158 | end 159 | ``` -------------------------------------------------------------------------------- /test/services/plan_resolver_pay_integration_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class PlanResolverPayIntegrationTest < ActiveSupport::TestCase 6 | def setup 7 | super 8 | # Ensure pay is defined for these tests 9 | Object.const_set(:Pay, Module.new) unless defined?(Pay) 10 | end 11 | 12 | def test_maps_plan_when_stripe_price_is_string_and_subscription_processor_plan_matches 13 | PricingPlans.reset_configuration! 14 | PricingPlans.configure do |config| 15 | config.default_plan = :free 16 | config.plan :free do 17 | price 0 18 | end 19 | config.plan :pro do 20 | stripe_price "price_pro_ABC" 21 | end 22 | end 23 | 24 | org = create_organization(pay_subscription: { active: true, processor_plan: "price_pro_ABC" }) 25 | assert_equal :pro, PricingPlans::PlanResolver.effective_plan_for(org).key 26 | end 27 | 28 | def test_maps_plan_when_stripe_price_is_hash_with_id 29 | PricingPlans.reset_configuration! 30 | PricingPlans.configure do |config| 31 | config.default_plan = :free 32 | config.plan :free do 33 | price 0 34 | end 35 | config.plan :pro do 36 | stripe_price({ id: "price_hash_ID", month: "price_month_X", year: "price_year_Y" }) 37 | end 38 | end 39 | 40 | org = create_organization(pay_subscription: { active: true, processor_plan: "price_hash_ID" }) 41 | assert_equal :pro, PricingPlans::PlanResolver.effective_plan_for(org).key 42 | end 43 | 44 | def test_maps_plan_when_stripe_price_is_hash_with_month 45 | PricingPlans.reset_configuration! 46 | PricingPlans.configure do |config| 47 | config.default_plan = :free 48 | config.plan :free do 49 | price 0 50 | end 51 | config.plan :pro do 52 | stripe_price({ month: "price_month_only" }) 53 | end 54 | end 55 | 56 | org = create_organization(pay_subscription: { active: true, processor_plan: "price_month_only" }) 57 | assert_equal :pro, PricingPlans::PlanResolver.effective_plan_for(org).key 58 | end 59 | 60 | def test_maps_plan_when_stripe_price_is_hash_with_year 61 | PricingPlans.reset_configuration! 62 | PricingPlans.configure do |config| 63 | config.default_plan = :free 64 | config.plan :free do 65 | price 0 66 | end 67 | config.plan :pro do 68 | stripe_price({ year: "price_year_only" }) 69 | end 70 | end 71 | 72 | org = create_organization(pay_subscription: { active: true, processor_plan: "price_year_only" }) 73 | assert_equal :pro, PricingPlans::PlanResolver.effective_plan_for(org).key 74 | end 75 | 76 | def test_maps_plan_when_multiple_plans_with_different_stripe_prices 77 | PricingPlans.reset_configuration! 78 | PricingPlans.configure do |config| 79 | config.default_plan = :free 80 | config.plan :free do 81 | price 0 82 | end 83 | config.plan :pro do 84 | stripe_price "price_pro_111" 85 | end 86 | config.plan :premium do 87 | stripe_price({ month: "price_premium_month", year: "price_premium_year" }) 88 | end 89 | end 90 | 91 | org = create_organization(pay_subscription: { active: true, processor_plan: "price_premium_month" }) 92 | assert_equal :premium, PricingPlans::PlanResolver.effective_plan_for(org).key 93 | end 94 | 95 | def test_maps_plan_when_on_trial 96 | PricingPlans.reset_configuration! 97 | PricingPlans.configure do |config| 98 | config.default_plan = :free 99 | config.plan :free do 100 | price 0 101 | end 102 | config.plan :pro do 103 | stripe_price "price_pro_trial" 104 | end 105 | end 106 | 107 | org = create_organization(pay_trial: true, pay_subscription: { processor_plan: "price_pro_trial" }) 108 | assert_equal :pro, PricingPlans::PlanResolver.effective_plan_for(org).key 109 | end 110 | 111 | def test_maps_plan_when_on_grace_period 112 | PricingPlans.reset_configuration! 113 | PricingPlans.configure do |config| 114 | config.default_plan = :free 115 | config.plan :free do 116 | price 0 117 | end 118 | config.plan :pro do 119 | stripe_price "price_pro_grace" 120 | end 121 | end 122 | 123 | org = create_organization(pay_grace_period: true, pay_subscription: { processor_plan: "price_pro_grace" }) 124 | assert_equal :pro, PricingPlans::PlanResolver.effective_plan_for(org).key 125 | end 126 | 127 | def test_chooses_matching_subscription_from_collection 128 | PricingPlans.reset_configuration! 129 | PricingPlans.configure do |config| 130 | config.default_plan = :free 131 | config.plan :free do 132 | price 0 133 | end 134 | config.plan :pro do 135 | stripe_price "price_pro_collection" 136 | end 137 | end 138 | 139 | org = create_organization 140 | # Provide multiple subscriptions; only one has the right processor_plan 141 | sub1 = OpenStruct.new(active?: false, on_trial?: false, on_grace_period?: false, processor_plan: "price_other") 142 | sub2 = OpenStruct.new(active?: true, on_trial?: false, on_grace_period?: false, processor_plan: "price_pro_collection") 143 | org.define_singleton_method(:subscriptions) { [sub1, sub2] } 144 | org.define_singleton_method(:subscription) { nil } 145 | 146 | assert_equal :pro, PricingPlans::PlanResolver.effective_plan_for(org).key 147 | end 148 | 149 | def test_falls_back_to_default_when_processor_plan_unknown 150 | PricingPlans.reset_configuration! 151 | PricingPlans.configure do |config| 152 | config.default_plan = :free 153 | config.plan :free do 154 | price 0 155 | end 156 | config.plan :pro do 157 | stripe_price "price_known" 158 | end 159 | end 160 | 161 | org = create_organization(pay_subscription: { active: true, processor_plan: "price_unknown" }) 162 | assert_equal :free, PricingPlans::PlanResolver.effective_plan_for(org).key 163 | end 164 | 165 | def test_no_pay_available_graceful_fallback 166 | org = create_organization 167 | PricingPlans::PlanResolver.stub(:pay_available?, false) do 168 | assert_equal :free, PricingPlans::PlanResolver.effective_plan_for(org).key 169 | end 170 | end 171 | end 172 | -------------------------------------------------------------------------------- /test/view_helpers_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class ViewHelpersTest < ActiveSupport::TestCase 6 | 7 | def setup 8 | super 9 | @org = create_organization 10 | end 11 | 12 | # Test the helper logic without relying on ActionView 13 | # These test the business logic of the helpers 14 | 15 | def test_plan_allows_with_allowed_feature 16 | plan = PricingPlans::Plan.new(:pro) 17 | plan.allows(:api_access) 18 | 19 | PricingPlans::PlanResolver.stub(:effective_plan_for, plan) do 20 | assert @org.plan_allows?(:api_access) 21 | end 22 | end 23 | 24 | def test_plan_allows_with_disallowed_feature 25 | plan = PricingPlans::Plan.new(:free) 26 | 27 | PricingPlans::PlanResolver.stub(:effective_plan_for, plan) do 28 | refute @org.plan_allows?(:api_access) 29 | end 30 | end 31 | 32 | def test_plan_limit_remaining 33 | PricingPlans::LimitChecker.stub(:plan_limit_remaining, 5) do 34 | assert_equal 5, @org.plan_limit_remaining(:projects) 35 | end 36 | end 37 | 38 | def test_plan_limit_remaining_unlimited 39 | PricingPlans::LimitChecker.stub(:plan_limit_remaining, :unlimited) do 40 | assert_equal :unlimited, @org.plan_limit_remaining(:projects) 41 | end 42 | end 43 | 44 | def test_plan_limit_percent_used 45 | PricingPlans::LimitChecker.stub(:plan_limit_percent_used, 75.5) do 46 | assert_equal 75.5, @org.plan_limit_percent_used(:projects) 47 | end 48 | end 49 | 50 | def test_limit_status_basic 51 | status = PricingPlans.limit_status(:projects, plan_owner: @org) 52 | assert_equal true, status[:configured] 53 | assert_equal :projects, status[:limit_key] 54 | assert_includes [:unlimited, Integer], status[:limit_amount].class 55 | assert_includes [true, false], status[:grace_active] 56 | assert_includes [true, false], status[:blocked] 57 | end 58 | 59 | def test_plans_returns_array 60 | data = PricingPlans.plans 61 | assert data.is_a?(Array) 62 | assert data.first.is_a?(PricingPlans::Plan) 63 | end 64 | 65 | def test_aggregate_helpers 66 | org = @org 67 | # No grace initially 68 | assert_equal :ok, org.limits_severity(:projects, :custom_models) 69 | # Simulate grace start for projects 70 | PricingPlans::GraceManager.mark_exceeded!(org, :projects) 71 | assert_includes [:grace, :blocked], org.limits_severity(:projects, :custom_models) 72 | ensure 73 | PricingPlans::GraceManager.reset_state!(org, :projects) 74 | end 75 | 76 | # New pure-data helpers 77 | def test_limit_severity_ok_warning_grace_blocked 78 | org = @org 79 | # Projects limit on :free is 1; initially 0/1 → :ok 80 | assert_equal :ok, org.limit_severity(:projects) 81 | 82 | # Simulate grace → should be :grace unless strictly blocked 83 | PricingPlans::GraceManager.mark_exceeded!(org, :projects) 84 | assert_includes [:grace, :blocked], org.limit_severity(:projects) 85 | ensure 86 | PricingPlans::GraceManager.reset_state!(org, :projects) 87 | end 88 | 89 | def test_limit_message_nil_when_ok 90 | org = @org 91 | assert_nil org.limit_message(:projects) 92 | 93 | # Simulate over limit by stubbing usage 94 | PricingPlans::LimitChecker.stub(:current_usage_for, 2) do 95 | assert_kind_of String, org.limit_message(:projects) 96 | end 97 | end 98 | 99 | def test_limit_overage 100 | org = @org 101 | assert_equal 0, org.limit_overage(:projects) 102 | 103 | # at limit (1) → 0 overage 104 | PricingPlans::LimitChecker.stub(:current_usage_for, 1) do 105 | assert_equal 0, org.limit_overage(:projects) 106 | end 107 | 108 | # over limit (2) → 1 overage 109 | PricingPlans::LimitChecker.stub(:current_usage_for, 2) do 110 | assert_equal 1, org.limit_overage(:projects) 111 | end 112 | end 113 | 114 | def test_limit_attention_and_approaching 115 | org = @org 116 | refute org.attention_required_for_limit?(:projects) 117 | refute org.approaching_limit?(:projects) # no warn_at crossed 118 | 119 | # Stub to 100% of 1 allowed → crosses highest warn threshold 120 | PricingPlans::LimitChecker.stub(:current_usage_for, 1) do 121 | PricingPlans::LimitChecker.stub(:plan_limit_percent_used, 100.0) do 122 | assert org.attention_required_for_limit?(:projects) 123 | assert org.approaching_limit?(:projects) 124 | assert org.approaching_limit?(:projects, at: 0.5) 125 | end 126 | end 127 | end 128 | 129 | def test_plan_cta_falls_back_to_defaults 130 | org = @org 131 | PricingPlans.configuration.default_cta_text = "Upgrade Plan" 132 | PricingPlans.configuration.default_cta_url = "/pricing" 133 | 134 | data = org.plan_cta 135 | assert_equal({ text: "Upgrade Plan", url: "/pricing" }, data) 136 | ensure 137 | PricingPlans.configuration.default_cta_text = nil 138 | PricingPlans.configuration.default_cta_url = nil 139 | end 140 | 141 | def test_limit_alert_view_model 142 | org = @org 143 | vm = org.limit_alert(:projects) 144 | assert_equal false, vm[:visible?] 145 | 146 | PricingPlans::LimitChecker.stub(:current_usage_for, 2) do 147 | vm = org.limit_alert(:projects) 148 | assert_equal true, vm[:visible?] 149 | assert_includes [:warning, :grace, :blocked], vm[:severity] 150 | assert_kind_of String, vm[:title] 151 | assert_kind_of Integer, vm[:overage] 152 | assert_includes vm.keys, :cta_text 153 | assert_includes vm.keys, :cta_url 154 | end 155 | end 156 | 157 | def test_at_limit_severity_and_message 158 | org = @org 159 | # Simulate exactly at limit (1/1) 160 | PricingPlans::LimitChecker.stub(:current_usage_for, 1) do 161 | PricingPlans::LimitChecker.stub(:plan_limit_percent_used, 100.0) do 162 | # No grace and not blocked (for free plan projects after_limit: :block_usage, severity should be :blocked at >= limit) 163 | # For a limit with grace_then_block, at_limit should appear 164 | # Switch to pro plan where :projects => 10; stub limit_status to mimic per plan 165 | st = PricingPlans.limit_status(:projects, plan_owner: org) 166 | # Baseline: ensure message exists when not OK 167 | msg = org.limit_message(:projects) 168 | assert_nil msg if org.limit_severity(:projects) == :ok 169 | end 170 | end 171 | end 172 | end 173 | -------------------------------------------------------------------------------- /lib/pricing_plans/registry.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PricingPlans 4 | class Registry 5 | class << self 6 | def build_from_configuration(configuration) 7 | @plans = configuration.plans.dup 8 | @configuration = configuration 9 | @event_handlers = configuration.event_handlers.dup 10 | 11 | validate_registry! 12 | lint_usage_credits_integration! 13 | attach_plan_owner_helpers! 14 | attach_pending_association_limits! 15 | 16 | self 17 | end 18 | 19 | def clear! 20 | @plans = nil 21 | @configuration = nil 22 | @event_handlers = nil 23 | end 24 | 25 | def plans 26 | @plans || {} 27 | end 28 | 29 | def plan(key) 30 | plan_obj = plans[key.to_sym] 31 | raise PlanNotFoundError, "Plan #{key} not found" unless plan_obj 32 | plan_obj 33 | end 34 | 35 | def plan_exists?(key) 36 | plans.key?(key.to_sym) 37 | end 38 | 39 | def configuration 40 | @configuration 41 | end 42 | 43 | def event_handlers 44 | @event_handlers || { warning: {}, grace_start: {}, block: {} } 45 | end 46 | 47 | def plan_owner_class 48 | return nil unless @configuration 49 | 50 | value = @configuration.plan_owner_class 51 | return nil unless value 52 | 53 | case value 54 | when String 55 | value.constantize 56 | when Class 57 | value 58 | else 59 | raise ConfigurationError, "plan_owner_class must be a string or class" 60 | end 61 | end 62 | 63 | def default_plan 64 | return nil unless @configuration 65 | plan(@configuration.default_plan) 66 | end 67 | 68 | def highlighted_plan 69 | return nil unless @configuration 70 | if @configuration.highlighted_plan 71 | return plan(@configuration.highlighted_plan) 72 | end 73 | # Fallback to plan flagged highlighted in DSL 74 | plans.values.find(&:highlighted?) 75 | end 76 | 77 | def emit_event(event_type, limit_key, *args) 78 | handler = event_handlers.dig(event_type, limit_key) 79 | handler&.call(*args) 80 | end 81 | 82 | private 83 | 84 | def validate_registry! 85 | # Check for duplicate stripe price IDs 86 | stripe_prices = plans.values 87 | .map(&:stripe_price) 88 | .compact 89 | .flat_map do |sp| 90 | case sp 91 | when String 92 | [sp] 93 | when Hash 94 | # Extract all price ID values from the hash 95 | [sp[:id], sp[:month], sp[:year]].compact 96 | else 97 | [] 98 | end 99 | end 100 | 101 | duplicates = stripe_prices.group_by(&:itself).select { |_, v| v.size > 1 }.keys 102 | if duplicates.any? 103 | raise ConfigurationError, "Duplicate Stripe price IDs found: #{duplicates.join(', ')}" 104 | end 105 | 106 | # Validate limit configurations 107 | validate_limit_consistency! 108 | end 109 | 110 | def attach_plan_owner_helpers! 111 | klass = plan_owner_class rescue nil 112 | return unless klass 113 | return if klass.included_modules.include?(PricingPlans::PlanOwner) 114 | klass.include(PricingPlans::PlanOwner) 115 | rescue StandardError 116 | # If plan_owner class isn't available yet, skip silently. 117 | end 118 | 119 | def attach_pending_association_limits! 120 | PricingPlans::AssociationLimitRegistry.flush_pending! 121 | end 122 | 123 | def validate_limit_consistency! 124 | all_limits = plans.values.flat_map do |plan| 125 | plan.limits.map { |key, limit| [plan.key, key, limit] } 126 | end 127 | 128 | # Group by limit key to check consistency 129 | limit_groups = all_limits.group_by { |_, limit_key, _| limit_key } 130 | 131 | limit_groups.each do |limit_key, limit_configs| 132 | # Filter out unlimited limits from consistency check 133 | non_unlimited_configs = limit_configs.reject { |_, _, limit| limit[:to] == :unlimited } 134 | 135 | # Check that all non-unlimited plans with the same limit key use consistent per: configuration 136 | per_values = non_unlimited_configs.map { |_, _, limit| limit[:per] }.uniq 137 | 138 | # Remove nil values to check if there are mixed per/non-per configurations 139 | non_nil_per_values = per_values.compact 140 | 141 | # If we have both nil and non-nil per values, that's inconsistent 142 | # If we have multiple different non-nil per values, that's also inconsistent 143 | has_nil = per_values.include?(nil) 144 | has_non_nil = non_nil_per_values.any? 145 | 146 | if (has_nil && has_non_nil) || non_nil_per_values.size > 1 147 | raise ConfigurationError, 148 | "Inconsistent 'per' configuration for limit '#{limit_key}': #{per_values.compact}" 149 | end 150 | end 151 | end 152 | 153 | def usage_credits_available? 154 | defined?(UsageCredits) 155 | end 156 | 157 | def lint_usage_credits_integration! 158 | # With single-currency credits, we only enforce separation of concerns: 159 | # - pricing_plans shows declared total credits per plan (cosmetic) 160 | # - usage_credits owns operations, costs, fulfillment, and spending 161 | # There is no per-operation credits declaration here anymore. 162 | # Still enforce that if you choose to model a metered dimension as credits in your app, 163 | # you should not also define a per-period limit with the same semantic key. 164 | credit_operation_keys = if usage_credits_available? 165 | UsageCredits.registry.operations.keys.map(&:to_sym) rescue [] 166 | else 167 | [] 168 | end 169 | 170 | plans.each do |_plan_key, plan| 171 | plan.limits.each do |limit_key, limit| 172 | next unless limit[:per] # Only per-period limits 173 | if credit_operation_keys.include?(limit_key.to_sym) 174 | raise ConfigurationError, 175 | "Limit '#{limit_key}' is also a usage_credits operation. Use credits (usage_credits) OR a per-period limit (pricing_plans), not both." 176 | end 177 | end 178 | end 179 | end 180 | end 181 | end 182 | end 183 | -------------------------------------------------------------------------------- /test/models/usage_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class UsageTest < ActiveSupport::TestCase 6 | def setup 7 | super # This calls the test helper setup which configures plans 8 | @org = create_organization 9 | @period_start = Time.parse("2025-01-01 00:00:00 UTC") 10 | @period_end = Time.parse("2025-01-31 23:59:59 UTC") 11 | end 12 | 13 | def test_factory_creates_valid_usage 14 | usage = PricingPlans::Usage.create!( 15 | plan_owner: @org, 16 | limit_key: "custom_models", 17 | period_start: @period_start, 18 | period_end: @period_end, 19 | used: 5 20 | ) 21 | 22 | assert usage.valid? 23 | assert_equal @org, usage.plan_owner 24 | assert_equal "custom_models", usage.limit_key 25 | assert_equal 5, usage.used 26 | end 27 | 28 | def test_validation_requires_limit_key 29 | usage = PricingPlans::Usage.new( 30 | plan_owner: @org, 31 | period_start: @period_start, 32 | period_end: @period_end 33 | ) 34 | 35 | refute usage.valid? 36 | assert usage.errors[:limit_key].any? 37 | end 38 | 39 | def test_validation_requires_period_start 40 | usage = PricingPlans::Usage.new( 41 | plan_owner: @org, 42 | limit_key: "custom_models", 43 | period_end: @period_end 44 | ) 45 | 46 | refute usage.valid? 47 | assert usage.errors[:period_start].any? 48 | end 49 | 50 | def test_validation_requires_period_end 51 | usage = PricingPlans::Usage.new( 52 | plan_owner: @org, 53 | limit_key: "custom_models", 54 | period_start: @period_start 55 | ) 56 | 57 | refute usage.valid? 58 | assert usage.errors[:period_end].any? 59 | end 60 | 61 | def test_uniqueness_constraint 62 | PricingPlans::Usage.create!( 63 | plan_owner: @org, 64 | limit_key: "custom_models", 65 | period_start: @period_start, 66 | period_end: @period_end, 67 | used: 1 68 | ) 69 | 70 | duplicate = PricingPlans::Usage.new( 71 | plan_owner: @org, 72 | limit_key: "custom_models", 73 | period_start: @period_start, 74 | period_end: @period_end, 75 | used: 2 76 | ) 77 | 78 | refute duplicate.valid? 79 | assert duplicate.errors[:period_start].any? 80 | end 81 | 82 | def test_different_periods_allowed 83 | usage1 = PricingPlans::Usage.create!( 84 | plan_owner: @org, 85 | limit_key: "custom_models", 86 | period_start: @period_start, 87 | period_end: @period_end, 88 | used: 1 89 | ) 90 | 91 | next_month_start = Time.parse("2025-02-01 00:00:00 UTC") 92 | next_month_end = Time.parse("2025-02-28 23:59:59 UTC") 93 | 94 | usage2 = PricingPlans::Usage.create!( 95 | plan_owner: @org, 96 | limit_key: "custom_models", 97 | period_start: next_month_start, 98 | period_end: next_month_end, 99 | used: 2 100 | ) 101 | 102 | assert usage1.valid? 103 | assert usage2.valid? 104 | end 105 | 106 | def test_different_limit_keys_allowed 107 | usage1 = PricingPlans::Usage.create!( 108 | plan_owner: @org, 109 | limit_key: "custom_models", 110 | period_start: @period_start, 111 | period_end: @period_end, 112 | used: 1 113 | ) 114 | 115 | usage2 = PricingPlans::Usage.create!( 116 | plan_owner: @org, 117 | limit_key: "api_calls", 118 | period_start: @period_start, 119 | period_end: @period_end, 120 | used: 100 121 | ) 122 | 123 | assert usage1.valid? 124 | assert usage2.valid? 125 | end 126 | 127 | def test_different_plan_owners_allowed 128 | org2 = create_organization 129 | 130 | usage1 = PricingPlans::Usage.create!( 131 | plan_owner: @org, 132 | limit_key: "custom_models", 133 | period_start: @period_start, 134 | period_end: @period_end, 135 | used: 1 136 | ) 137 | 138 | usage2 = PricingPlans::Usage.create!( 139 | plan_owner: org2, 140 | limit_key: "custom_models", 141 | period_start: @period_start, 142 | period_end: @period_end, 143 | used: 2 144 | ) 145 | 146 | assert usage1.valid? 147 | assert usage2.valid? 148 | end 149 | 150 | def test_used_defaults_to_zero 151 | usage = PricingPlans::Usage.create!( 152 | plan_owner: @org, 153 | limit_key: "custom_models", 154 | period_start: @period_start, 155 | period_end: @period_end 156 | ) 157 | 158 | assert_equal 0, usage.used 159 | end 160 | 161 | def test_polymorphic_plan_owner_association 162 | usage = PricingPlans::Usage.create!( 163 | plan_owner: @org, 164 | limit_key: "custom_models", 165 | period_start: @period_start, 166 | period_end: @period_end 167 | ) 168 | 169 | assert_equal @org, usage.plan_owner 170 | assert_equal "Organization", usage.plan_owner_type 171 | assert_equal @org.id, usage.plan_owner_id 172 | end 173 | 174 | def test_increment_method_increases_used 175 | usage = PricingPlans::Usage.create!( 176 | plan_owner: @org, 177 | limit_key: "custom_models", 178 | period_start: @period_start, 179 | period_end: @period_end, 180 | used: 5 181 | ) 182 | 183 | usage.increment! 184 | assert_equal 6, usage.used 185 | 186 | usage.increment!(3) 187 | assert_equal 9, usage.used 188 | end 189 | 190 | def test_last_used_at_timestamp 191 | usage = PricingPlans::Usage.create!( 192 | plan_owner: @org, 193 | limit_key: "custom_models", 194 | period_start: @period_start, 195 | period_end: @period_end 196 | ) 197 | 198 | travel_to(Time.parse("2025-01-15 10:30:00 UTC")) do 199 | usage.update!(last_used_at: Time.current, used: 1) 200 | assert_equal Time.parse("2025-01-15 10:30:00 UTC"), usage.last_used_at 201 | end 202 | end 203 | 204 | def test_find_or_create_usage_pattern 205 | # Test the common pattern used in the Limitable module 206 | usage = PricingPlans::Usage.find_or_initialize_by( 207 | plan_owner: @org, 208 | limit_key: "custom_models", 209 | period_start: @period_start, 210 | period_end: @period_end 211 | ) 212 | 213 | assert usage.new_record? 214 | 215 | usage.used = 1 216 | usage.last_used_at = Time.current 217 | usage.save! 218 | 219 | # Find existing record 220 | existing = PricingPlans::Usage.find_or_initialize_by( 221 | plan_owner: @org, 222 | limit_key: "custom_models", 223 | period_start: @period_start, 224 | period_end: @period_end 225 | ) 226 | 227 | refute existing.new_record? 228 | assert_equal 1, existing.used 229 | end 230 | end 231 | -------------------------------------------------------------------------------- /test/models/assignment_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class AssignmentTest < ActiveSupport::TestCase 6 | def setup 7 | super # This calls the test helper setup which configures plans 8 | @org = create_organization 9 | end 10 | 11 | def test_factory_creates_valid_assignment 12 | # Verify plans are configured 13 | assert PricingPlans::Registry.plan_exists?(:pro), "Pro plan should exist" 14 | 15 | assignment = PricingPlans::Assignment.create!( 16 | plan_owner: @org, 17 | plan_key: "pro", 18 | source: "manual" 19 | ) 20 | 21 | assert assignment.valid? 22 | assert_equal @org, assignment.plan_owner 23 | assert_equal "pro", assignment.plan_key 24 | assert_equal "manual", assignment.source 25 | end 26 | 27 | def test_validation_requires_plan_owner 28 | assignment = PricingPlans::Assignment.new( 29 | plan_key: "pro", 30 | source: "manual" 31 | ) 32 | 33 | refute assignment.valid? 34 | assert assignment.errors[:plan_owner].any? 35 | end 36 | 37 | def test_validation_requires_plan_key 38 | assignment = PricingPlans::Assignment.new( 39 | plan_owner: @org, 40 | source: "manual" 41 | ) 42 | 43 | refute assignment.valid? 44 | assert assignment.errors[:plan_key].any? 45 | end 46 | 47 | def test_validation_requires_source 48 | assignment = PricingPlans::Assignment.new( 49 | plan_owner: @org, 50 | plan_key: "pro", 51 | source: nil # Explicitly set to nil since it has default 52 | ) 53 | 54 | refute assignment.valid? 55 | assert assignment.errors[:source].any? 56 | end 57 | 58 | def test_source_defaults_to_manual 59 | assignment = PricingPlans::Assignment.create!( 60 | plan_owner: @org, 61 | plan_key: "pro" 62 | ) 63 | 64 | assert_equal "manual", assignment.source 65 | end 66 | 67 | def test_uniqueness_per_plan_owner 68 | PricingPlans::Assignment.create!( 69 | plan_owner: @org, 70 | plan_key: "pro", 71 | source: "manual" 72 | ) 73 | 74 | duplicate = PricingPlans::Assignment.new( 75 | plan_owner: @org, 76 | plan_key: "enterprise", 77 | source: "admin" 78 | ) 79 | 80 | refute duplicate.valid? 81 | assert duplicate.errors[:plan_owner_type].any? 82 | end 83 | 84 | def test_different_plan_owners_can_have_assignments 85 | org2 = create_organization 86 | 87 | assignment1 = PricingPlans::Assignment.create!( 88 | plan_owner: @org, 89 | plan_key: "pro", 90 | source: "manual" 91 | ) 92 | 93 | assignment2 = PricingPlans::Assignment.create!( 94 | plan_owner: org2, 95 | plan_key: "enterprise", 96 | source: "admin" 97 | ) 98 | 99 | assert assignment1.valid? 100 | assert assignment2.valid? 101 | end 102 | 103 | def test_polymorphic_plan_owner_association 104 | assignment = PricingPlans::Assignment.create!( 105 | plan_owner: @org, 106 | plan_key: "pro" 107 | ) 108 | 109 | assert_equal @org, assignment.plan_owner 110 | assert_equal "Organization", assignment.plan_owner_type 111 | assert_equal @org.id, assignment.plan_owner_id 112 | end 113 | 114 | def test_assign_plan_to_class_method 115 | result = PricingPlans::Assignment.assign_plan_to(@org, :enterprise, source: "admin") 116 | 117 | assert result.persisted? 118 | assert_equal "enterprise", result.plan_key 119 | assert_equal "admin", result.source 120 | assert_equal @org, result.plan_owner 121 | end 122 | 123 | def test_assign_plan_to_updates_existing_assignment 124 | existing = PricingPlans::Assignment.create!( 125 | plan_owner: @org, 126 | plan_key: "pro", 127 | source: "manual" 128 | ) 129 | 130 | updated = PricingPlans::Assignment.assign_plan_to(@org, :enterprise, source: "admin") 131 | 132 | assert_equal existing.id, updated.id 133 | assert_equal "enterprise", updated.plan_key 134 | assert_equal "admin", updated.source 135 | end 136 | 137 | def test_assign_plan_to_with_string_plan_key 138 | result = PricingPlans::Assignment.assign_plan_to(@org, "pro") 139 | 140 | assert_equal "pro", result.plan_key 141 | end 142 | 143 | def test_remove_assignment_for_class_method 144 | PricingPlans::Assignment.create!( 145 | plan_owner: @org, 146 | plan_key: "pro" 147 | ) 148 | 149 | assert_difference "PricingPlans::Assignment.count", -1 do 150 | PricingPlans::Assignment.remove_assignment_for(@org) 151 | end 152 | end 153 | 154 | def test_remove_assignment_for_nonexistent_assignment 155 | assert_no_difference "PricingPlans::Assignment.count" do 156 | PricingPlans::Assignment.remove_assignment_for(@org) 157 | end 158 | end 159 | 160 | def test_find_by_plan_owner 161 | assignment = PricingPlans::Assignment.create!( 162 | plan_owner: @org, 163 | plan_key: "pro" 164 | ) 165 | 166 | found = PricingPlans::Assignment.find_by( 167 | plan_owner_type: @org.class.name, 168 | plan_owner_id: @org.id 169 | ) 170 | 171 | assert_equal assignment, found 172 | end 173 | 174 | def test_integration_with_plan_resolver 175 | PricingPlans::Assignment.assign_plan_to(@org, :pro) 176 | 177 | resolved_plan = PricingPlans::PlanResolver.effective_plan_for(@org) 178 | assert_equal :pro, resolved_plan.key 179 | end 180 | 181 | def test_multiple_sources 182 | # Test different source types 183 | sources = ["manual", "admin", "api", "migration", "trial_conversion"] 184 | 185 | sources.each_with_index do |source, index| 186 | org = create_organization 187 | assignment = PricingPlans::Assignment.create!( 188 | plan_owner: org, 189 | plan_key: "pro", 190 | source: source 191 | ) 192 | 193 | assert assignment.valid? 194 | assert_equal source, assignment.source 195 | end 196 | end 197 | 198 | def test_assignment_history_via_timestamps 199 | travel_to(Time.parse("2025-01-01 12:00:00 UTC")) do 200 | assignment = PricingPlans::Assignment.create!( 201 | plan_owner: @org, 202 | plan_key: "free" 203 | ) 204 | 205 | assert_in_delta Time.parse("2025-01-01 12:00:00 UTC"), assignment.created_at, 1.second 206 | end 207 | 208 | travel_to(Time.parse("2025-01-15 12:00:00 UTC")) do 209 | assignment = PricingPlans::Assignment.find_by(plan_owner: @org) 210 | PricingPlans::Assignment.assign_plan_to(@org, :pro, source: "upgrade") 211 | 212 | updated_assignment = PricingPlans::Assignment.find_by(plan_owner: @org) 213 | assert updated_assignment.updated_at > assignment.updated_at 214 | end 215 | end 216 | end 217 | -------------------------------------------------------------------------------- /test/models/enforcement_state_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class EnforcementStateTest < ActiveSupport::TestCase 6 | def setup 7 | super # This calls the test helper setup which configures plans 8 | @org = create_organization 9 | end 10 | 11 | def test_factory_creates_valid_state 12 | state = PricingPlans::EnforcementState.create!( 13 | plan_owner: @org, 14 | limit_key: "projects" 15 | ) 16 | 17 | assert state.valid? 18 | assert_equal @org, state.plan_owner 19 | assert_equal "projects", state.limit_key 20 | end 21 | 22 | def test_validation_requires_limit_key 23 | state = PricingPlans::EnforcementState.new(plan_owner: @org) 24 | 25 | refute state.valid? 26 | assert state.errors[:limit_key].any? 27 | end 28 | 29 | def test_uniqueness_validation 30 | PricingPlans::EnforcementState.create!( 31 | plan_owner: @org, 32 | limit_key: "projects" 33 | ) 34 | 35 | duplicate = PricingPlans::EnforcementState.new( 36 | plan_owner: @org, 37 | limit_key: "projects" 38 | ) 39 | 40 | refute duplicate.valid? 41 | assert duplicate.errors[:limit_key].any? 42 | end 43 | 44 | def test_exceeded_scope_and_method 45 | exceeded_state = PricingPlans::EnforcementState.create!( 46 | plan_owner: @org, 47 | limit_key: "projects", 48 | exceeded_at: Time.current 49 | ) 50 | 51 | not_exceeded_state = PricingPlans::EnforcementState.create!( 52 | plan_owner: create_organization, 53 | limit_key: "projects" 54 | ) 55 | 56 | assert_includes PricingPlans::EnforcementState.exceeded, exceeded_state 57 | refute_includes PricingPlans::EnforcementState.exceeded, not_exceeded_state 58 | 59 | assert exceeded_state.exceeded? 60 | refute not_exceeded_state.exceeded? 61 | end 62 | 63 | def test_blocked_scope_and_method 64 | blocked_state = PricingPlans::EnforcementState.create!( 65 | plan_owner: @org, 66 | limit_key: "projects", 67 | exceeded_at: Time.current, 68 | blocked_at: Time.current 69 | ) 70 | 71 | not_blocked_state = PricingPlans::EnforcementState.create!( 72 | plan_owner: create_organization, 73 | limit_key: "projects", 74 | exceeded_at: Time.current 75 | ) 76 | 77 | assert_includes PricingPlans::EnforcementState.blocked, blocked_state 78 | refute_includes PricingPlans::EnforcementState.blocked, not_blocked_state 79 | 80 | assert blocked_state.blocked? 81 | refute not_blocked_state.blocked? 82 | end 83 | 84 | def test_in_grace_scope_and_method 85 | in_grace_state = PricingPlans::EnforcementState.create!( 86 | plan_owner: @org, 87 | limit_key: "projects", 88 | exceeded_at: Time.current 89 | ) 90 | 91 | blocked_state = PricingPlans::EnforcementState.create!( 92 | plan_owner: create_organization, 93 | limit_key: "projects", 94 | exceeded_at: Time.current, 95 | blocked_at: Time.current 96 | ) 97 | 98 | not_exceeded_state = PricingPlans::EnforcementState.create!( 99 | plan_owner: create_organization, 100 | limit_key: "projects" 101 | ) 102 | 103 | assert_includes PricingPlans::EnforcementState.in_grace, in_grace_state 104 | refute_includes PricingPlans::EnforcementState.in_grace, blocked_state 105 | refute_includes PricingPlans::EnforcementState.in_grace, not_exceeded_state 106 | 107 | assert in_grace_state.in_grace? 108 | refute blocked_state.in_grace? 109 | refute not_exceeded_state.in_grace? 110 | end 111 | 112 | def test_grace_ends_at_calculation 113 | state = PricingPlans::EnforcementState.create!( 114 | plan_owner: @org, 115 | limit_key: "projects", 116 | exceeded_at: Time.parse("2025-01-01 12:00:00 UTC"), 117 | data: { "grace_period" => 7.days.to_i } 118 | ) 119 | 120 | expected_end = Time.parse("2025-01-08 12:00:00 UTC") 121 | assert_equal expected_end, state.grace_ends_at 122 | end 123 | 124 | def test_grace_ends_at_nil_without_exceeded_at 125 | state = PricingPlans::EnforcementState.create!( 126 | plan_owner: @org, 127 | limit_key: "projects" 128 | ) 129 | 130 | assert_nil state.grace_ends_at 131 | end 132 | 133 | def test_grace_expired_when_past_grace_period 134 | travel_to(Time.parse("2025-01-01 12:00:00 UTC")) do 135 | state = PricingPlans::EnforcementState.create!( 136 | plan_owner: @org, 137 | limit_key: "projects", 138 | exceeded_at: Time.current, 139 | data: { "grace_period" => 7.days.to_i } 140 | ) 141 | 142 | refute state.grace_expired? 143 | end 144 | 145 | travel_to(Time.parse("2025-01-08 12:00:01 UTC")) do 146 | state = PricingPlans::EnforcementState.find_by(plan_owner: @org, limit_key: "projects") 147 | assert state.grace_expired? 148 | end 149 | end 150 | 151 | def test_grace_expired_false_without_grace_end 152 | state = PricingPlans::EnforcementState.create!( 153 | plan_owner: @org, 154 | limit_key: "projects" 155 | ) 156 | 157 | refute state.grace_expired? 158 | end 159 | 160 | def test_polymorphic_plan_owner_association 161 | # Test with different types of plan_owners 162 | state1 = PricingPlans::EnforcementState.create!( 163 | plan_owner: @org, 164 | limit_key: "projects" 165 | ) 166 | 167 | # Create a different type of plan_owner for testing 168 | project = Project.create!(name: "Test", organization: @org) 169 | state2 = PricingPlans::EnforcementState.create!( 170 | plan_owner: project, 171 | limit_key: "some_limit" 172 | ) 173 | 174 | assert_equal @org, state1.plan_owner 175 | assert_equal "Organization", state1.plan_owner_type 176 | assert_equal @org.id, state1.plan_owner_id 177 | 178 | assert_equal project, state2.plan_owner 179 | assert_equal "Project", state2.plan_owner_type 180 | assert_equal project.id, state2.plan_owner_id 181 | end 182 | 183 | def test_json_data_field_defaults_to_empty_hash 184 | state = PricingPlans::EnforcementState.create!( 185 | plan_owner: @org, 186 | limit_key: "projects" 187 | ) 188 | 189 | assert_equal({}, state.data) 190 | end 191 | 192 | def test_json_data_field_stores_and_retrieves_data 193 | state = PricingPlans::EnforcementState.create!( 194 | plan_owner: @org, 195 | limit_key: "projects", 196 | data: { 197 | "grace_period" => 10.days.to_i, 198 | "custom_info" => "test" 199 | } 200 | ) 201 | 202 | state.reload 203 | 204 | assert_equal 10.days.to_i, state.data["grace_period"] 205 | assert_equal "test", state.data["custom_info"] 206 | end 207 | end 208 | -------------------------------------------------------------------------------- /lib/pricing_plans/period_calculator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PricingPlans 4 | class PeriodCalculator 5 | class << self 6 | def window_for(plan_owner, limit_key) 7 | plan = PlanResolver.effective_plan_for(plan_owner) 8 | limit_config = plan&.limit_for(limit_key) 9 | 10 | period_type = determine_period_type(limit_config) 11 | calculate_window_for_period(plan_owner, period_type) 12 | end 13 | 14 | private 15 | 16 | # Backward-compatible shim for tests that stub pay_available? 17 | def pay_available? 18 | PaySupport.pay_available? 19 | end 20 | 21 | def determine_period_type(limit_config) 22 | # First check the limit's specific per: configuration 23 | return limit_config[:per] if limit_config&.dig(:per) 24 | 25 | # Fall back to global configuration 26 | Registry.configuration.period_cycle 27 | end 28 | 29 | def calculate_window_for_period(plan_owner, period_type) 30 | case period_type 31 | when :billing_cycle 32 | billing_cycle_window(plan_owner) 33 | when :calendar_month, :month 34 | calendar_month_window 35 | when :calendar_week, :week 36 | calendar_week_window 37 | when :calendar_day, :day 38 | calendar_day_window 39 | when ->(x) { x.respond_to?(:call) } 40 | # Custom callable 41 | result = period_type.call(plan_owner) 42 | validate_custom_window!(result) 43 | result 44 | else 45 | # Handle ActiveSupport duration objects 46 | if period_type.respond_to?(:seconds) 47 | duration_window(period_type) 48 | else 49 | raise ConfigurationError, "Unknown period type: #{period_type}" 50 | end 51 | end 52 | end 53 | 54 | def billing_cycle_window(plan_owner) 55 | # Respect tests that stub pay availability 56 | return fallback_window unless pay_available? 57 | 58 | subscription = nil 59 | if plan_owner.respond_to?(:subscription) 60 | subscription = plan_owner.subscription 61 | end 62 | if subscription.nil? && plan_owner.respond_to?(:subscriptions) 63 | # Prefer a sub with explicit period anchors 64 | subscription = plan_owner.subscriptions.find do |sub| 65 | sub.respond_to?(:current_period_start) && sub.respond_to?(:current_period_end) 66 | end 67 | # Otherwise, fall back to any active/trial/grace subscription 68 | subscription ||= plan_owner.subscriptions.find do |sub| 69 | (sub.respond_to?(:active?) && sub.active?) || 70 | (sub.respond_to?(:on_trial?) && sub.on_trial?) || 71 | (sub.respond_to?(:on_grace_period?) && sub.on_grace_period?) 72 | end 73 | end 74 | subscription ||= PaySupport.current_subscription_for(plan_owner) 75 | 76 | return fallback_window unless subscription 77 | 78 | # Use Pay's billing cycle anchor if available 79 | if subscription.respond_to?(:current_period_start) && 80 | subscription.respond_to?(:current_period_end) 81 | [subscription.current_period_start, subscription.current_period_end] 82 | elsif subscription.respond_to?(:created_at) 83 | # Calculate from subscription creation date 84 | start_time = subscription.created_at 85 | monthly_window_from(start_time) 86 | else 87 | fallback_window 88 | end 89 | end 90 | 91 | def calendar_month_window 92 | now = Time.current 93 | start_time = now.beginning_of_month 94 | end_time = now.end_of_month 95 | [start_time, end_time] 96 | end 97 | 98 | def calendar_week_window 99 | now = Time.current 100 | start_time = now.beginning_of_week 101 | end_time = now.end_of_week 102 | [start_time, end_time] 103 | end 104 | 105 | def calendar_day_window 106 | now = Time.current 107 | start_time = now.beginning_of_day 108 | end_time = now.end_of_day 109 | [start_time, end_time] 110 | end 111 | 112 | def duration_window(duration) 113 | now = Time.current 114 | start_time = now.beginning_of_day 115 | end_time = start_time + duration 116 | [start_time, end_time] 117 | end 118 | 119 | def monthly_window_from(anchor_date) 120 | now = Time.current 121 | 122 | # Find the current period based on anchor date 123 | months_since = ((now.year - anchor_date.year) * 12 + (now.month - anchor_date.month)) 124 | 125 | start_time = anchor_date + months_since.months 126 | end_time = start_time + 1.month 127 | 128 | # If we've passed this period, move to the next one 129 | if now >= end_time 130 | start_time = end_time 131 | end_time = start_time + 1.month 132 | end 133 | 134 | [start_time, end_time] 135 | end 136 | 137 | # Removed duplicate Pay helpers; centralized in PaySupport 138 | 139 | def fallback_window 140 | # Default to calendar month if billing cycle unavailable 141 | calendar_month_window 142 | end 143 | 144 | def validate_custom_window!(window) 145 | unless window.is_a?(Array) && window.size == 2 146 | raise ConfigurationError, "Custom period callable must return [start_time, end_time]" 147 | end 148 | 149 | start_time, end_time = window 150 | 151 | unless start_time&.respond_to?(:to_time) && end_time&.respond_to?(:to_time) 152 | raise ConfigurationError, "Custom period window times must respond to :to_time" 153 | end 154 | 155 | begin 156 | # Convert explicitly to UTC to avoid Rails 8.1 to_time deprecation noise 157 | start_time_converted = 158 | if start_time.is_a?(Time) 159 | start_time 160 | elsif start_time.respond_to?(:to_time) 161 | start_time.to_time(:utc) 162 | else 163 | Time.parse(start_time.to_s) 164 | end 165 | 166 | end_time_converted = 167 | if end_time.is_a?(Time) 168 | end_time 169 | elsif end_time.respond_to?(:to_time) 170 | end_time.to_time(:utc) 171 | else 172 | Time.parse(end_time.to_s) 173 | end 174 | if end_time_converted <= start_time_converted 175 | raise ConfigurationError, "Custom period end_time must be after start_time" 176 | end 177 | rescue NoMethodError 178 | raise ConfigurationError, "Custom period window times must respond to :to_time" 179 | end 180 | end 181 | end 182 | end 183 | end 184 | -------------------------------------------------------------------------------- /test/result_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class ResultTest < ActiveSupport::TestCase 6 | def test_within_result 7 | result = PricingPlans::Result.within("You have 5 projects remaining") 8 | 9 | assert result.ok? 10 | assert result.within? 11 | refute result.warning? 12 | refute result.grace? 13 | refute result.blocked? 14 | 15 | assert_equal :within, result.state 16 | assert_equal "You have 5 projects remaining", result.message 17 | assert_nil result.limit_key 18 | assert_nil result.plan_owner 19 | end 20 | 21 | def test_warning_result 22 | org = create_organization 23 | result = PricingPlans::Result.warning( 24 | "You're approaching your project limit", 25 | limit_key: :projects, 26 | plan_owner: org 27 | ) 28 | 29 | refute result.ok? 30 | assert result.warning? 31 | refute result.within? 32 | refute result.grace? 33 | refute result.blocked? 34 | 35 | assert_equal :warning, result.state 36 | assert_equal "You're approaching your project limit", result.message 37 | assert_equal :projects, result.limit_key 38 | assert_equal org, result.plan_owner 39 | end 40 | 41 | def test_grace_result 42 | org = create_organization 43 | result = PricingPlans::Result.grace( 44 | "You've exceeded your limit but are in grace period", 45 | limit_key: :projects, 46 | plan_owner: org 47 | ) 48 | 49 | refute result.ok? 50 | refute result.warning? 51 | assert result.grace? 52 | refute result.within? 53 | refute result.blocked? 54 | 55 | assert_equal :grace, result.state 56 | assert_equal "You've exceeded your limit but are in grace period", result.message 57 | assert_equal :projects, result.limit_key 58 | assert_equal org, result.plan_owner 59 | end 60 | 61 | def test_blocked_result 62 | org = create_organization 63 | result = PricingPlans::Result.blocked( 64 | "You've exceeded your limit and grace period has expired", 65 | limit_key: :projects, 66 | plan_owner: org 67 | ) 68 | 69 | refute result.ok? 70 | refute result.warning? 71 | refute result.grace? 72 | refute result.within? 73 | assert result.blocked? 74 | 75 | assert_equal :blocked, result.state 76 | assert_equal "You've exceeded your limit and grace period has expired", result.message 77 | assert_equal :projects, result.limit_key 78 | assert_equal org, result.plan_owner 79 | end 80 | 81 | def test_ok_method_convenience 82 | within_result = PricingPlans::Result.within("OK") 83 | warning_result = PricingPlans::Result.warning("Warning") 84 | grace_result = PricingPlans::Result.grace("Grace") 85 | blocked_result = PricingPlans::Result.blocked("Blocked") 86 | 87 | assert within_result.ok? 88 | refute warning_result.ok? 89 | refute grace_result.ok? 90 | refute blocked_result.ok? 91 | end 92 | 93 | def test_result_with_nil_message 94 | result = PricingPlans::Result.within(nil) 95 | 96 | assert_nil result.message 97 | assert result.ok? 98 | end 99 | 100 | def test_result_with_empty_message 101 | result = PricingPlans::Result.within("") 102 | 103 | assert_equal "", result.message 104 | assert result.ok? 105 | end 106 | 107 | def test_result_state_constants 108 | assert_equal :within, PricingPlans::Result.within("test").state 109 | assert_equal :warning, PricingPlans::Result.warning("test").state 110 | assert_equal :grace, PricingPlans::Result.grace("test").state 111 | assert_equal :blocked, PricingPlans::Result.blocked("test").state 112 | end 113 | 114 | def test_result_optional_parameters_default_to_nil 115 | result = PricingPlans::Result.warning("Warning message") 116 | 117 | assert_nil result.limit_key 118 | assert_nil result.plan_owner 119 | end 120 | 121 | def test_result_stores_limit_key_as_symbol 122 | result = PricingPlans::Result.warning("Test", limit_key: "projects") 123 | 124 | assert_equal "projects", result.limit_key 125 | end 126 | 127 | def test_result_immutability 128 | org = create_organization 129 | result = PricingPlans::Result.grace( 130 | "Grace message", 131 | limit_key: :projects, 132 | plan_owner: org 133 | ) 134 | 135 | # Result should be effectively immutable (no setter methods) 136 | refute result.respond_to?(:message=) 137 | refute result.respond_to?(:state=) 138 | refute result.respond_to?(:limit_key=) 139 | refute result.respond_to?(:plan_owner=) 140 | end 141 | 142 | def test_result_equality_based_on_attributes 143 | org = create_organization 144 | 145 | result1 = PricingPlans::Result.grace("Message", limit_key: :projects, plan_owner: org) 146 | result2 = PricingPlans::Result.grace("Message", limit_key: :projects, plan_owner: org) 147 | result3 = PricingPlans::Result.grace("Different", limit_key: :projects, plan_owner: org) 148 | 149 | # Note: Results are value objects, they won't be == unless explicitly implemented 150 | # But they should have the same attributes 151 | assert_equal result1.state, result2.state 152 | assert_equal result1.message, result2.message 153 | assert_equal result1.limit_key, result2.limit_key 154 | assert_equal result1.plan_owner, result2.plan_owner 155 | 156 | refute_equal result1.message, result3.message 157 | end 158 | 159 | def test_result_can_be_used_in_controller_pattern 160 | org = create_organization 161 | result = PricingPlans::Result.blocked( 162 | "Cannot create project: limit exceeded", 163 | limit_key: :projects, 164 | plan_owner: org 165 | ) 166 | 167 | # Simulate controller usage 168 | if result.blocked? 169 | assert_equal "Cannot create project: limit exceeded", result.message 170 | assert_equal :projects, result.limit_key 171 | else 172 | flunk "Expected blocked result" 173 | end 174 | end 175 | 176 | def test_result_truthiness_for_flow_control 177 | ok_result = PricingPlans::Result.within("OK") 178 | warning_result = PricingPlans::Result.warning("Warning") 179 | blocked_result = PricingPlans::Result.blocked("Blocked") 180 | 181 | # All results are truthy objects (not nil/false) 182 | assert ok_result 183 | assert warning_result 184 | assert blocked_result 185 | end 186 | 187 | def test_result_with_complex_plan_owner_objects 188 | # Test with different types of plan_owners 189 | project = Project.create!(name: "Test", organization: create_organization) 190 | 191 | result = PricingPlans::Result.warning( 192 | "Warning for project", 193 | limit_key: :some_limit, 194 | plan_owner: project 195 | ) 196 | 197 | assert_equal project, result.plan_owner 198 | assert_equal :some_limit, result.limit_key 199 | end 200 | end 201 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 4 | 5 | begin 6 | require "simplecov" 7 | SimpleCov.start do 8 | enable_coverage :branch 9 | add_filter "/test/" 10 | end 11 | rescue LoadError 12 | # SimpleCov not available in some environments 13 | end 14 | 15 | require "pricing_plans" 16 | require "minitest/autorun" 17 | require "minitest/pride" if ENV["PRIDE"] 18 | require "active_record" 19 | require "active_support" 20 | require "active_support/test_case" 21 | require "active_support/time" 22 | require "ostruct" 23 | 24 | # Mock Pay for testing 25 | module Pay 26 | # Mock Pay module to simulate its presence for testing 27 | end 28 | 29 | # Set up test database 30 | ActiveRecord::Base.establish_connection( 31 | adapter: "sqlite3", 32 | database: ":memory:" 33 | ) 34 | 35 | # Load schema 36 | ActiveRecord::Schema.define do 37 | create_table :pricing_plans_enforcement_states do |t| 38 | t.string :plan_owner_type, null: false 39 | t.bigint :plan_owner_id, null: false 40 | t.string :limit_key, null: false 41 | t.datetime :exceeded_at 42 | t.datetime :blocked_at 43 | t.decimal :last_warning_threshold, precision: 3, scale: 2 44 | t.datetime :last_warning_at 45 | t.json :data, default: {} 46 | 47 | t.timestamps 48 | end 49 | 50 | add_index :pricing_plans_enforcement_states, 51 | [:plan_owner_type, :plan_owner_id, :limit_key], 52 | unique: true, 53 | name: 'idx_pricing_plans_enforcement_unique' 54 | 55 | create_table :pricing_plans_usages do |t| 56 | t.string :plan_owner_type, null: false 57 | t.bigint :plan_owner_id, null: false 58 | t.string :limit_key, null: false 59 | t.datetime :period_start, null: false 60 | t.datetime :period_end, null: false 61 | t.bigint :used, default: 0, null: false 62 | t.datetime :last_used_at 63 | 64 | t.timestamps 65 | end 66 | 67 | add_index :pricing_plans_usages, 68 | [:plan_owner_type, :plan_owner_id, :limit_key, :period_start], 69 | unique: true, 70 | name: 'idx_pricing_plans_usages_unique' 71 | 72 | create_table :pricing_plans_assignments do |t| 73 | t.string :plan_owner_type, null: false 74 | t.bigint :plan_owner_id, null: false 75 | t.string :plan_key, null: false 76 | t.string :source, null: false, default: 'manual' 77 | 78 | t.timestamps 79 | end 80 | 81 | add_index :pricing_plans_assignments, 82 | [:plan_owner_type, :plan_owner_id], 83 | unique: true 84 | 85 | # Test models 86 | create_table :organizations do |t| 87 | t.string :name 88 | t.timestamps 89 | end 90 | 91 | create_table :projects do |t| 92 | t.string :name 93 | t.references :organization, null: false 94 | t.timestamps 95 | end 96 | 97 | create_table :custom_models do |t| 98 | t.string :name 99 | t.references :organization, null: false 100 | t.timestamps 101 | end 102 | end 103 | 104 | # Test models 105 | class Organization < ActiveRecord::Base 106 | include PricingPlans::PlanOwner 107 | has_many :projects, dependent: :destroy 108 | has_many :custom_models, dependent: :destroy 109 | 110 | # Mock Pay methods for testing 111 | attr_accessor :pay_subscription, :pay_trial, :pay_grace_period 112 | 113 | def pay_enabled? 114 | true # Always enabled for testing 115 | end 116 | 117 | def subscribed? 118 | pay_subscription.present? && pay_subscription[:active] 119 | end 120 | 121 | def on_trial? 122 | pay_trial.present? 123 | end 124 | 125 | def on_grace_period? 126 | pay_grace_period.present? 127 | end 128 | 129 | def subscription 130 | return nil unless subscribed? || on_trial? || on_grace_period? 131 | 132 | OpenStruct.new( 133 | processor_plan: pay_subscription&.dig(:processor_plan), 134 | active?: subscribed?, 135 | on_trial?: on_trial?, 136 | on_grace_period?: on_grace_period?, 137 | current_period_start: 1.month.ago, 138 | current_period_end: 1.day.from_now, 139 | created_at: 2.months.ago 140 | ) 141 | end 142 | end 143 | 144 | class Project < ActiveRecord::Base 145 | belongs_to :organization 146 | include PricingPlans::Limitable 147 | limited_by_pricing_plans :projects, plan_owner: :organization 148 | end 149 | 150 | class CustomModel < ActiveRecord::Base 151 | belongs_to :organization 152 | include PricingPlans::Limitable 153 | limited_by_pricing_plans :custom_models, plan_owner: :organization, per: :month 154 | end 155 | 156 | # Test configuration helper 157 | module TestConfigurationHelper 158 | def setup_test_plans 159 | PricingPlans.reset_configuration! 160 | 161 | PricingPlans.configure do |config| 162 | config.default_plan = :free 163 | config.highlighted_plan = :pro 164 | config.period_cycle = :billing_cycle 165 | 166 | config.plan :free do 167 | name "Free" 168 | description "Basic plan" 169 | price 0 170 | bullets "Limited features" 171 | 172 | limits :projects, to: 1, after_limit: :block_usage 173 | limits :custom_models, to: 0, per: :month 174 | disallows :api_access 175 | end 176 | 177 | config.plan :pro do 178 | stripe_price "price_pro_123" 179 | name "Pro" 180 | bullets "Advanced features", "API access" 181 | 182 | allows :api_access 183 | limits :projects, to: 10 184 | limits :custom_models, to: 3, per: :month, after_limit: :grace_then_block, grace: 7.days 185 | includes_credits 1000 186 | end 187 | 188 | config.plan :enterprise do 189 | price_string "Contact us" 190 | name "Enterprise" 191 | allows :api_access 192 | unlimited :projects, :custom_models 193 | end 194 | end 195 | end 196 | end 197 | 198 | class ActiveSupport::TestCase 199 | include TestConfigurationHelper 200 | 201 | def setup 202 | super 203 | setup_test_plans 204 | 205 | # Re-register model counters after configuration reset 206 | Project.send(:limited_by_pricing_plans, :projects, plan_owner: :organization) if Project.respond_to?(:limited_by_pricing_plans) 207 | CustomModel.send(:limited_by_pricing_plans, :custom_models, plan_owner: :organization, per: :month) if CustomModel.respond_to?(:limited_by_pricing_plans) 208 | 209 | # Clean up between tests 210 | PricingPlans::EnforcementState.destroy_all 211 | PricingPlans::Usage.destroy_all 212 | PricingPlans::Assignment.destroy_all 213 | Organization.destroy_all 214 | end 215 | 216 | def create_organization(attrs = {}) 217 | Organization.create!(name: "Test Org", **attrs) 218 | end 219 | 220 | def travel_to_time(time) 221 | Time.stub(:current, time) do 222 | yield 223 | end 224 | end 225 | 226 | def stub_usage_credits_available 227 | Object.const_set(:UsageCredits, Class.new) unless defined?(UsageCredits) 228 | 229 | registry = Class.new do 230 | def self.operations 231 | { api_calls: :api_calls } 232 | end 233 | end 234 | 235 | UsageCredits.define_singleton_method(:registry) { registry } 236 | end 237 | 238 | def unstub_usage_credits 239 | Object.send(:remove_const, :UsageCredits) if defined?(UsageCredits) 240 | end 241 | end 242 | -------------------------------------------------------------------------------- /lib/pricing_plans/configuration.rb: -------------------------------------------------------------------------------- 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 190 | -------------------------------------------------------------------------------- /test/services/plan_resolver_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class PlanResolverTest < ActiveSupport::TestCase 6 | def test_effective_plan_with_active_subscription 7 | org = create_organization( 8 | pay_subscription: { active: true, processor_plan: "price_pro_123" } 9 | ) 10 | 11 | plan = PricingPlans::PlanResolver.effective_plan_for(org) 12 | 13 | assert_equal :pro, plan.key 14 | end 15 | 16 | def test_effective_plan_with_trial_subscription 17 | org = create_organization( 18 | pay_trial: true, 19 | pay_subscription: { processor_plan: "price_pro_123" } 20 | ) 21 | 22 | plan = PricingPlans::PlanResolver.effective_plan_for(org) 23 | 24 | assert_equal :pro, plan.key 25 | end 26 | 27 | def test_effective_plan_with_grace_period_subscription 28 | org = create_organization( 29 | pay_grace_period: true, 30 | pay_subscription: { processor_plan: "price_pro_123" } 31 | ) 32 | 33 | plan = PricingPlans::PlanResolver.effective_plan_for(org) 34 | 35 | assert_equal :pro, plan.key 36 | end 37 | 38 | def test_effective_plan_with_manual_assignment 39 | org = create_organization 40 | 41 | PricingPlans::Assignment.assign_plan_to(org, :enterprise) 42 | 43 | plan = PricingPlans::PlanResolver.effective_plan_for(org) 44 | 45 | assert_equal :enterprise, plan.key 46 | end 47 | 48 | def test_effective_plan_falls_back_to_default 49 | org = create_organization 50 | 51 | plan = PricingPlans::PlanResolver.effective_plan_for(org) 52 | 53 | assert_equal :free, plan.key 54 | end 55 | 56 | def test_effective_plan_prioritizes_pay_over_assignment 57 | org = create_organization( 58 | pay_subscription: { active: true, processor_plan: "price_pro_123" } 59 | ) 60 | 61 | # Manual assignment should be ignored when Pay subscription is active 62 | PricingPlans::Assignment.assign_plan_to(org, :enterprise) 63 | 64 | plan = PricingPlans::PlanResolver.effective_plan_for(org) 65 | 66 | assert_equal :pro, plan.key # Pay subscription wins 67 | end 68 | 69 | def test_effective_plan_with_unknown_processor_plan 70 | org = create_organization( 71 | pay_subscription: { active: true, processor_plan: "price_unknown_999" } 72 | ) 73 | 74 | # Should fall back to manual assignment or default since processor plan not found 75 | plan = PricingPlans::PlanResolver.effective_plan_for(org) 76 | 77 | assert_equal :free, plan.key 78 | end 79 | 80 | def test_effective_plan_with_inactive_subscription_but_manual_assignment 81 | org = create_organization( 82 | pay_subscription: { active: false, processor_plan: "price_pro_123" } 83 | ) 84 | 85 | PricingPlans::Assignment.assign_plan_to(org, :enterprise) 86 | 87 | plan = PricingPlans::PlanResolver.effective_plan_for(org) 88 | 89 | assert_equal :enterprise, plan.key 90 | end 91 | 92 | def test_plan_key_for_convenience_method 93 | org = create_organization( 94 | pay_subscription: { active: true, processor_plan: "price_pro_123" } 95 | ) 96 | 97 | plan_key = PricingPlans::PlanResolver.plan_key_for(org) 98 | 99 | assert_equal :pro, plan_key 100 | end 101 | 102 | def test_assign_plan_manually 103 | org = create_organization 104 | 105 | assignment = PricingPlans::PlanResolver.assign_plan_manually!(org, :pro, source: "admin") 106 | 107 | assert_equal "pro", assignment.plan_key 108 | assert_equal "admin", assignment.source 109 | 110 | # Verify it affects plan resolution 111 | plan = PricingPlans::PlanResolver.effective_plan_for(org) 112 | assert_equal :pro, plan.key 113 | end 114 | 115 | def test_remove_manual_assignment 116 | org = create_organization 117 | 118 | PricingPlans::PlanResolver.assign_plan_manually!(org, :pro) 119 | 120 | # Verify assignment exists 121 | plan = PricingPlans::PlanResolver.effective_plan_for(org) 122 | assert_equal :pro, plan.key 123 | 124 | PricingPlans::PlanResolver.remove_manual_assignment!(org) 125 | 126 | # Should fall back to default 127 | plan = PricingPlans::PlanResolver.effective_plan_for(org) 128 | assert_equal :free, plan.key 129 | end 130 | 131 | def test_complex_stripe_price_matching 132 | # Test hash-based stripe price matching 133 | PricingPlans.reset_configuration! 134 | 135 | PricingPlans.configure do |config| 136 | config.default_plan = :free 137 | 138 | config.plan :free do 139 | price 0 140 | end 141 | 142 | config.plan :pro do 143 | stripe_price({ month: "price_monthly", year: "price_yearly" }) 144 | end 145 | end 146 | 147 | org = create_organization( 148 | pay_subscription: { active: true, processor_plan: "price_monthly" } 149 | ) 150 | 151 | plan = PricingPlans::PlanResolver.effective_plan_for(org) 152 | 153 | assert_equal :pro, plan.key 154 | end 155 | 156 | def test_pay_gem_not_available_graceful_fallback 157 | org = create_organization 158 | 159 | # Stub out the pay_available? method to return false 160 | PricingPlans::PlanResolver.stub(:pay_available?, false) do 161 | plan = PricingPlans::PlanResolver.effective_plan_for(org) 162 | 163 | # Should go straight to manual assignment / default 164 | assert_equal :free, plan.key 165 | end 166 | end 167 | 168 | def test_plan_owner_without_pay_methods 169 | # Create a basic object without Pay methods 170 | basic_org = Object.new 171 | 172 | plan = PricingPlans::PlanResolver.effective_plan_for(basic_org) 173 | 174 | # Should fall back to default (no manual assignments for non-AR objects) 175 | assert_equal :free, plan.key 176 | end 177 | 178 | def test_subscription_with_nil_processor_plan 179 | org = create_organization( 180 | pay_subscription: { active: true, processor_plan: nil } 181 | ) 182 | 183 | plan = PricingPlans::PlanResolver.effective_plan_for(org) 184 | 185 | assert_equal :free, plan.key 186 | end 187 | 188 | def test_multiple_subscription_scenarios 189 | org = create_organization 190 | 191 | # Mock multiple subscriptions scenario 192 | subscription1 = OpenStruct.new(active?: false, on_trial?: false, on_grace_period?: false) 193 | subscription2 = OpenStruct.new( 194 | active?: true, 195 | on_trial?: false, 196 | on_grace_period?: false, 197 | processor_plan: "price_pro_123" 198 | ) 199 | 200 | org.define_singleton_method(:subscriptions) { [subscription1, subscription2] } 201 | org.define_singleton_method(:subscription) { nil } # Primary subscription inactive 202 | 203 | # Should find the active one 204 | plan = PricingPlans::PlanResolver.effective_plan_for(org) 205 | 206 | assert_equal :pro, plan.key 207 | end 208 | 209 | def test_edge_case_empty_string_processor_plan 210 | org = create_organization( 211 | pay_subscription: { active: true, processor_plan: "" } 212 | ) 213 | 214 | plan = PricingPlans::PlanResolver.effective_plan_for(org) 215 | 216 | assert_equal :free, plan.key 217 | end 218 | 219 | def test_edge_case_subscription_method_returns_nil 220 | org = create_organization( 221 | pay_subscription: { active: true, processor_plan: "price_pro_123" } 222 | ) 223 | 224 | # Override subscription method to return nil 225 | org.define_singleton_method(:subscription) { nil } 226 | 227 | plan = PricingPlans::PlanResolver.effective_plan_for(org) 228 | 229 | assert_equal :free, plan.key 230 | end 231 | 232 | def test_plan_resolution_caches_per_request 233 | org = create_organization 234 | # Initially default plan 235 | assert_equal :free, PricingPlans::PlanResolver.effective_plan_for(org).key 236 | 237 | # Assign plan; should reflect immediately since we don’t cache plan resolution here 238 | PricingPlans::PlanResolver.assign_plan_manually!(org, :pro) 239 | assert_equal :pro, PricingPlans::PlanResolver.effective_plan_for(org).key 240 | end 241 | end 242 | -------------------------------------------------------------------------------- /test/services/registry_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class RegistryTest < ActiveSupport::TestCase 6 | def setup 7 | super 8 | # Reset configuration for each test since we're testing configuration itself 9 | PricingPlans.reset_configuration! 10 | end 11 | 12 | def test_builds_from_configuration 13 | PricingPlans.configure do |config| 14 | config.default_plan = :free 15 | 16 | config.plan :free do 17 | price 0 18 | end 19 | end 20 | 21 | registry = PricingPlans::Registry 22 | 23 | assert_equal 1, registry.plans.size 24 | assert registry.plan_exists?(:free) 25 | assert_equal "Free", registry.plan(:free).name 26 | end 27 | 28 | def test_plan_not_found_error 29 | error = assert_raises(PricingPlans::PlanNotFoundError) do 30 | PricingPlans::Registry.plan(:nonexistent) 31 | end 32 | 33 | assert_match(/Plan nonexistent not found/, error.message) 34 | end 35 | 36 | def test_plan_owner_class_resolution_from_string 37 | PricingPlans.configure do |config| 38 | config.plan_owner_class = "Organization" 39 | config.default_plan = :free 40 | 41 | config.plan :free do 42 | price 0 43 | end 44 | end 45 | 46 | assert_equal Organization, PricingPlans::Registry.plan_owner_class 47 | end 48 | 49 | def test_plan_owner_class_resolution_from_class 50 | PricingPlans.configure do |config| 51 | config.plan_owner_class = Organization 52 | config.default_plan = :free 53 | 54 | config.plan :free do 55 | price 0 56 | end 57 | end 58 | 59 | assert_equal Organization, PricingPlans::Registry.plan_owner_class 60 | end 61 | 62 | def test_plan_owner_class_invalid_type 63 | error = assert_raises(PricingPlans::ConfigurationError) do 64 | PricingPlans.configure do |config| 65 | config.plan_owner_class = 123 # Invalid type 66 | config.default_plan = :free 67 | 68 | config.plan :free do 69 | price 0 70 | end 71 | end 72 | end 73 | 74 | assert_match(/plan_owner_class must be a string or class/, error.message) 75 | end 76 | 77 | def test_default_and_highlighted_plan_resolution 78 | PricingPlans.configure do |config| 79 | config.plan :free do 80 | price 0 81 | default! 82 | end 83 | 84 | config.plan :pro do 85 | price 29 86 | highlighted! 87 | end 88 | end 89 | 90 | registry = PricingPlans::Registry 91 | 92 | assert_equal :free, registry.default_plan.key 93 | assert_equal :pro, registry.highlighted_plan.key 94 | end 95 | 96 | def test_duplicate_stripe_price_validation 97 | error = assert_raises(PricingPlans::ConfigurationError) do 98 | PricingPlans.configure do |config| 99 | config.default_plan = :free 100 | 101 | config.plan :free do 102 | price 0 103 | end 104 | 105 | config.plan :pro do 106 | stripe_price "price_123" 107 | end 108 | 109 | config.plan :premium do 110 | stripe_price "price_123" # Duplicate! 111 | end 112 | end 113 | end 114 | 115 | assert_match(/Duplicate Stripe price IDs found: price_123/, error.message) 116 | end 117 | 118 | def test_limit_consistency_validation 119 | error = assert_raises(PricingPlans::ConfigurationError) do 120 | PricingPlans.configure do |config| 121 | config.default_plan = :free 122 | 123 | config.plan :free do 124 | price 0 125 | limits :projects, to: 1 # No 'per' option 126 | end 127 | 128 | config.plan :pro do 129 | price 29 130 | limits :projects, to: 10, per: :month # Has 'per' option - inconsistent! 131 | end 132 | end 133 | end 134 | 135 | assert_match(/Inconsistent 'per' configuration for limit 'projects'/, error.message) 136 | end 137 | 138 | def test_usage_credits_integration_linting_with_stubbed_gem 139 | stub_usage_credits_available 140 | 141 | error = assert_raises(PricingPlans::ConfigurationError) do 142 | PricingPlans.configure do |config| 143 | config.default_plan = :free 144 | 145 | config.plan :free do 146 | price 0 147 | limits :api_calls, to: 50, per: :month # Collision with usage_credits operation name 148 | end 149 | end 150 | end 151 | 152 | assert_match(/Use credits \(usage_credits\) OR a per-period limit/, error.message) 153 | 154 | ensure 155 | unstub_usage_credits 156 | end 157 | 158 | def test_event_emission 159 | handler_called = false 160 | handler_args = nil 161 | 162 | PricingPlans.configure do |config| 163 | config.default_plan = :free 164 | 165 | config.plan :free do 166 | price 0 167 | end 168 | 169 | config.on_warning :projects do |plan_owner, threshold| 170 | handler_called = true 171 | handler_args = [plan_owner, threshold] 172 | end 173 | end 174 | 175 | org = create_organization 176 | PricingPlans::Registry.emit_event(:warning, :projects, org, 0.8) 177 | 178 | assert handler_called 179 | assert_equal [org, 0.8], handler_args 180 | end 181 | 182 | def test_event_emission_with_no_handler 183 | # Should not raise error when no handler registered 184 | org = create_organization 185 | 186 | assert_nothing_raised do 187 | PricingPlans::Registry.emit_event(:warning, :nonexistent, org, 0.8) 188 | end 189 | end 190 | 191 | def test_clear_registry 192 | PricingPlans.configure do |config| 193 | config.default_plan = :free 194 | 195 | config.plan :free do 196 | price 0 197 | end 198 | end 199 | 200 | refute_empty PricingPlans::Registry.plans 201 | 202 | PricingPlans::Registry.clear! 203 | 204 | assert_empty PricingPlans::Registry.plans 205 | assert_nil PricingPlans::Registry.configuration 206 | end 207 | 208 | def test_registry_without_configuration 209 | PricingPlans::Registry.clear! 210 | 211 | assert_empty PricingPlans::Registry.plans 212 | assert_nil PricingPlans::Registry.plan_owner_class 213 | assert_nil PricingPlans::Registry.default_plan 214 | assert_nil PricingPlans::Registry.highlighted_plan 215 | end 216 | 217 | def test_complex_stripe_price_collision_detection 218 | error = assert_raises(PricingPlans::ConfigurationError) do 219 | PricingPlans.configure do |config| 220 | config.default_plan = :free 221 | 222 | config.plan :free do 223 | price 0 224 | end 225 | 226 | config.plan :pro do 227 | stripe_price({ month: "price_month", year: "price_year" }) 228 | end 229 | 230 | config.plan :premium do 231 | stripe_price "price_month" # Collides with pro's month price 232 | end 233 | end 234 | end 235 | 236 | assert_match(/Duplicate Stripe price IDs/, error.message) 237 | end 238 | 239 | def test_event_handlers_structure 240 | PricingPlans.configure do |config| 241 | config.default_plan = :free 242 | 243 | config.plan :free do 244 | price 0 245 | end 246 | 247 | config.on_warning :projects do |plan_owner, threshold| 248 | # handler 249 | end 250 | 251 | config.on_grace_start :projects do |plan_owner, ends_at| 252 | # handler 253 | end 254 | 255 | config.on_block :projects do |plan_owner| 256 | # handler 257 | end 258 | end 259 | 260 | handlers = PricingPlans::Registry.event_handlers 261 | 262 | assert handlers[:warning][:projects].is_a?(Proc) 263 | assert handlers[:grace_start][:projects].is_a?(Proc) 264 | assert handlers[:block][:projects].is_a?(Proc) 265 | end 266 | 267 | private 268 | 269 | def capture_io 270 | old_stdout = $stdout 271 | old_stderr = $stderr 272 | $stdout = StringIO.new 273 | $stderr = StringIO.new 274 | 275 | yield 276 | 277 | [$stderr.string, $stdout.string] 278 | ensure 279 | $stdout = old_stdout 280 | $stderr = old_stderr 281 | end 282 | end 283 | --------------------------------------------------------------------------------