app/models/plan.rb
Class `Plan` has 51 methods (exceeds 20 allowed). Consider refactoring.
File `plan.rb` has 316 lines of code (exceeds 250 allowed). Consider refactoring.
Plan has at least 48 methods
Plan assumes too much for instance variable '@cached_features'class Plan < ApplicationRecord include Searchable class PeriodRangeCalculationError < StandardError; end include Symbolize self.allowed_sort_columns = %w[name state contracts_count] self.default_sort_column = :name self.default_sort_direction = :asc include SystemName include Logic::MetricVisibility::Plan self.background_deletion = [:contracts, :plan_metrics, :pricing_rules, :usage_limits, [:customizations, { action: :destroy, class_name: 'Plan' }]] has_system_name :uniqueness_scope => [ :type, :issuer_id, :issuer_type ] audited :allow_mass_assignment => true acts_as_list scope: %i[issuer_id issuer_type] # There is a race condition here, the record gets deleted but the callbacks were not triggered yet # So we need to verify their existence before: # - `#lock!` triggered by acts_as_list # - `#audit_destroy` triggered by audit_destroy # I think those gems are fine with their behaviour. # The problem is on our side with so many inter-dependent relationships # skip_callback :destroy, :before, :lock!, if: -> { !self.class.exists?(id) || destroyed_by_association } skip_callback :destroy, :before, :audit_destroy, if: -> { !self.class.exists?(id) } validates :state, inclusion: { in: %w(hidden published) } before_validation(:on => :create) { set_state_to_hidden_if_nil } # otherwise the validation above fails as state machine hasnt kicked in yet (ugly) attr_accessible is recommended over attr_protected attr_protected :issuer_id, :original_id, :type, :issuer_type, :tenant_id, :audit_ids, :state state_machine :initial => :hidden do state :hidden state :published event :publish do transition :hidden => :published end event :hide do transition :published => :hidden end end symbolize :cost_aggregation_rule alias_attribute :backend_id, :id validates :cost_aggregation_rule, inclusion: { :in => [:sum, :max, :min], :message => 'is invalid' } validates :name, presence: true validates :setup_fee, :cost_per_month, numericality: { allow_nil: false, allow_blank: false, greater_than_or_equal_to: 0.00 } validates :trial_period_days, numericality: { allow_nil: true, greater_than_or_equal_to: 0 } validates :system_name, length: { maximum: 255 } validates :name, :rights, :state, :cost_aggregation_rule, :type, :issuer_type, length: { maximum: 255 } validates :full_legal, length: { maximum: 4294967295 } validates :description, length: { maximum: 65535 } # This association is redefined in child classes to take advantage of :inverse_of belongs_to :issuer, :polymorphic => true # Use `:prepend => true` so it is called before any other callback. # Especially there is a bug with *acts_as_list* that will call `#lock!` on the record before destroy # But calling `#lock!` will call `#reload` so some instance variables are reset before_destroy :avoid_destruction, prepend: true has_many :contracts, dependent: :destroy # TODO: move this to application plan, but beware # there is lot of code relying on these two methods on every plan has_many :usage_limits, as: :plan, dependent: :destroy do def visible where{ plan_metrics.visible.eq(true) | plan_metrics.plan_id.eq(nil) } .joins{ plan_metrics.outer }.references(:all) end include Logic::MetricVisibility::OfMetricAssociationProxy end has_many :pricing_rules, dependent: :destroy, &Logic::MetricVisibility::OfMetricAssociationProxy with_options(as: :plan, dependent: :destroy) do |plan| # having plan_metrics only in ApplicationPlan breaks eager loading, investigate after rails3 plan.has_many :plan_metrics, foreign_key: :plan_id, &Logic::MetricVisibility::OfMetricAssociationProxy end has_many :features_plans, as: :plan, dependent: :delete_all has_many :features, through: :features_plans do # Only enabled features # returns all features available for this plan, not only enabled ones def all_available owner = proxy_association.owner owner.issuer.features.with_object_scope(owner) end end has_many :customizations, :foreign_key => :original_id, :class_name => "Plan", :dependent => :destroy belongs_to :original, :class_name => self.name scope :latest, -> { limit(5).order(created_at: :desc) } scope :by_state, ->(state) { where({:state => state.to_s})} scope :by_type, ->(type) { where({ :type => type.to_s })} # Customzied plans scope :customized, -> { where("#{table_name}.original_id <> 0") } # TODO: DRY this - we only use one of those # Stock (not customized) plans. scope :stock, -> { where(original_id: [0, nil]) } scope :not_custom, -> { where(original_id: 0)} scope :ordered, -> { order(:id) } scope :alphabetically, -> { order(name: :asc) } def self.provided_by(account) where.has do id.in(Plan.issued_by(account).select(:id)) | id.in(Plan.issued_by(Service, account.service_ids).select(:id)) end end def self.issued_by(issuer, *ids) casePlan#self.issued_by calls 'ids.blank?' 2 times when (issuer == :all || issuer.blank?) && ids.blank? where({})Plan#self.issued_by manually dispatches method call when issuer.respond_to?(:to_model) && ids.blank? issued_by_type(issuer.class, issuer.id) else issued_by_type(issuer, *ids) end end def self.issued_by_type(issuer_type, *ids) where(:issuer_type => issuer_type.model_name.to_s, :issuer_id => ids.flatten) end # WARNING: same logic is present in the #free? method - if you change one, change also the other scope :paid, -> { where(["#{quoted_table_name}.cost_per_month != 0 OR #{quoted_table_name}.setup_fee != 0 OR pricing_rules.id IS NOT NULL"]).includes([:pricing_rules])} scope :free, -> { where(["#{quoted_table_name}.cost_per_month = 0 AND #{quoted_table_name}.setup_fee = 0 AND pricing_rules.id IS NULL"]).includes([:pricing_rules])} class << self def published by_state(:published) end def hidden by_state(:hidden).order(created_at: :desc) end alias by_issuer issued_by end def reset_contracts_counter update_column(:contracts_count, contracts.count) if persisted? end alias reset_counter_cache reset_contracts_counter def can_be_destroyed? return true if destroyed_by_association # checking if customizations have contracts is a bit too much, since # contract.change_plan! removes customizations, but is better to be sure if contracts.exists? errors.add :base, :has_contractsPlan#can_be_destroyed? has the variable name 'c' elsif customizations.any? { |c| c.contracts.exists? } errors.add :base, :customizations_has_contracts end errors.empty? end def cost_per_month money_in_currency self[:cost_per_month] end def setup_fee money_in_currency self[:setup_fee] end # Returns `provider_id`. See AccountPlan#currency_cache_key # def currency_cache_keyPlan#currency_cache_key manually dispatches method call provider_account.try!(:id) if respond_to?(:provider_account) end def currency if key = currency_cache_key Finance::BillingStrategy.account_currency(key) else Finance::BillingStrategy::CURRENCIES.values.first end end # Is this plan provided by given account? delegate :provided_by?, to: :service # Is there a contract used by given account? def bought_by?(account) account && contracts.bought_by(account).present? end # Set cancellation period in days. def cancellation_period_in_days=(days) self[:cancellation_period] = days.to_i.days end # Get cancellation period as number of days. This is just to make form # fields more user friendly. def cancellation_period_in_days self[:cancellation_period] / 1.day end def customize(attrs = {}) custom = copy(name: attrs[:name] || "#{name} (custom)", system_name: attrs[:system_name] || "#{system_name}_custom_#{randomized}", original: self, state: 'hidden') without_auditing_for_associations { custom.save } custom end Plan#without_auditing_for_associations doesn't depend on instance state (maybe move it to another class?) def without_auditing_for_associations ::UsageLimit.disable_auditing ::PricingRule.disable_auditing yield ensure ::PricingRule.enable_auditing ::UsageLimit.enable_auditing end # TODO: web_hook_failures use the same 'random' generator, dry itPlan#randomized doesn't depend on instance state (maybe move it to another class?) def randomized Time.now.utc.to_f.to_s.sub('.', '') # I know.. very RANDOM end Plan#copy has approx 9 statements def copy(attrs = {}) custom = dupPlan#copy refers to 'custom' more than self (maybe move it to another class?) custom.name = attrs[:name] || "#{custom.name} (copy)" custom.system_name = attrs[:system_name] || generate_copy_system_name custom.position = nil custom.contracts_count = custom.contracts.count # FIXME: there is bug in acts_as_state_machine # aasm sets state in on create, even if it is already set # so next line has no effect and cloned plan will be 'hidden' # TODO: lets use some decent solution like state_machine gem custom.state = attrs[:state] || statePlan#copy calls 'attrs[:original]' 2 times
Plan#copy performs a nil-check custom.original = attrs[:original] unless attrs[:original].nil? clone_associations(custom) custom end def customized?Plan#customized? performs a nil-check !original.nil? end def original_name customized? ? original.name : name end # Aggregate costs according to aggregation rule. # # Note that for any aggregation function +f+, this must hold: # # f(x1, x2, ..., xn) + f(y1, y2, ..., yn) = f(x1 + y1, x2 + y2, ..., xn + yn) # # Because the costs are updated incrementally (on each hit). def aggregate_costs(costs) money_in_currency costs.send(cost_aggregation_rule) end # WARNING: same logic is also present in 'free/paid' scopes; update # both in case of change # def free? self[:cost_per_month].zero? && self[:setup_fee].zero? && pricing_rules.empty? end def paid? !free? end delegate :trial?, :best_plan?, to: :plan_rule, allow_nil: true def reload(*) @cached_features = nil super end def includes_feature?(feature) @cached_features ||= Set.new(features.loaded? ? feature_ids : features.pluck(:id)) @cached_features.include?(feature.id) end # Fixed cost for given period, which can be less than one month. def cost_for_period(period) return money_in_currency(0) if (cost_per_month || 0).zero? Plan#cost_for_period calls 'period.end' 3 times
Plan#cost_for_period calls 'period.begin' 5 times same_month_period = (period.begin.month == period.end.month) && (period.begin.year == period.end.year) raise PeriodRangeCalculationError, 'Beginning and end of the period must both be in the same month' unless same_month_period # our ranges are actually correct but for arithmetic purposes we do want the + 1 (length of the range/month) month_part = (BigDecimal((period.end.to_i + 1).to_s) - BigDecimal(period.begin.to_i.to_s)) / (BigDecimal((period.begin.end_of_month.to_i + 1).to_s) - BigDecimal(period.begin.beginning_of_month.to_i.to_s)) (cost_per_month * month_part).round(2) end # Get cinstance of this plan bought by given user account. def cinstance_bought_by(account) cinstances.bought_by(account).first end # Enable feature with given name (system_name, can be symbol)Plan has missing safe method 'enable_feature!' def enable_feature!(name) unless feature_enabled?(name) self.features_plans.create :feature => service.features.find_by_system_name!(name.to_s) end end Plan has missing safe method 'disable_feature!' def disable_feature!(name) features.delete(service.features.find_by_system_name!(name.to_s)) end def feature_enabled?(name) features.any? { |feature| feature.system_name == name.to_s } end def master? method = "default_#{self.class.to_s.underscore}" issuer.try!(method) == self end def to_xml(options = {}) attrs = self.master? ? { :default => true } : {} xml_builder(options, attrs).to_xml end #TODO: test def usage_limits_for_widget self.usage_limits.includes(metric: :parent).order(:value).group_by(&:metric) end def pricing_rules_for_widget self.pricing_rules.includes(metric: :parent).group_by(&:metric) end #TODO: test def metrics_without_limits metrics_with_limits = Set.new self.usage_limits.includes(:metric).map(&:metric) all_metrics_of_plan = Set.new self.metrics return (all_metrics_of_plan - metrics_with_limits).to_a end def set_state_to_hidden_if_nil self.state ||= 'hidden' end # TODO: test what happens when you try to buy an already bought plan #Plan#create_contract_with has approx 9 statements def create_contract_with(buyer, opts = nil)Plan#create_contract_with is controlled by argument 'opts' params = opts || {} contract = contracts.build paramsPlan#create_contract_with refers to 'params' more than self (maybe move it to another class?)
Plan#create_contract_with refers to 'contract' more than self (maybe move it to another class?)
Plan#create_contract_with calls 'params["application_id"]' 2 times contract.application_id = params["application_id"] if params["application_id"]Plan#create_contract_with calls 'params["user_key"]' 2 times contract.user_key = params["user_key"] if params["user_key"] if buyer.new_record? # only build contract - it will be saved with account buyer.contracts << contract contract else # create contract directly from plan object contract.user_account_id = buyer.id contract.save contract end end # Same as +create_contract_with+, but # # - raises an exception on validation failure # - cannot be called on unsaved plan # def create_contract_with!(buyer, opts = nil) opts ||= {} new_contract = contracts.new optsPlan#create_contract_with! refers to 'new_contract' more than self (maybe move it to another class?)
Plan#create_contract_with! refers to 'opts' more than self (maybe move it to another class?)
Plan#create_contract_with! calls 'opts["application_id"]' 2 times new_contract.application_id = opts["application_id"] if opts["application_id"]Plan#create_contract_with! calls 'opts["user_key"]' 2 times new_contract.user_key = opts["user_key"] if opts["user_key"] new_contract.user_account_id = buyer.id # this attribute is now protected new_contract.save! new_contract end # pricing is enabled when provider's billing strategy is not nil def pricing_enabled? provider_account.billing_strategy && !provider_account.master_on_premises? end def limits plan_rule ? plan_rule.limits.to_h : {} end def switches plan_rule ? plan_rule.switches : [] end def scheduled_for_deletion? !issuer || issuer.deleted? || provider_account&.scheduled_for_deletion? end protected Plan#xml_builder has approx 14 statements def xml_builder(options, attrs = {}, extra_nodes = {}) xml = options[:builder] || ThreeScale::XML::Builder.new xml.plan(attrs) do |xml| xml.id_ id unless new_record? xml.name name xml.type_ self.class.to_s.underscore xml.state state xml.approval_required approval_required xml.setup_fee setup_fee xml.cost_per_month cost_per_month xml.trial_period_days trial_period_days xml.cancellation_period cancellation_period Plan#xml_builder contains iterators nested 2 deep extra_nodes.each do |key,value| xml.__send__(:method_missing, key, value) end end xml end def money_in_currency(amount) amount.try!(:to_has_money, currency) end def clone_associations(custom) features.each do |feature| custom.features_plans.build :feature => feature end end private # act_as_list updates the position every time that a plan is destroyed. # But we do not want to do that when its issuer is going to be deleted anyway. # Additionally, we cannot do: skip_callback :destroy, :after, :decrement_positions_on_lower_items, if: -> { destroyed_by_association } # because it is an 'after' callback and by the time it is executed, destroyed_by_association is already 'nil' def act_as_list_no_update? super || scheduled_for_deletion? end def plan_rule @plan_rule ||= PlanRulesCollection.find_for_plan(self) end def generate_copy_system_name separator = '_copy_'.freeze base, _ = system_name.split(separator, 2) [base.first(200), randomized].join(separator) end def avoid_destruction throw :abort unless can_be_destroyed? endend