lnagel/event-sourced-accounting

View on GitHub
app/models/esa/context.rb

Summary

Maintainability
B
5 hrs
Test Coverage
require 'esa/associations/amounts_extension'

module ESA
  # The Context provides a persisted filtered view on the objects related to a Chart.
  #
  # @author Lenno Nagel
  class Context < ActiveRecord::Base
    attr_accessible :chart, :chart_id, :parent, :parent_id, :type, :name, :namespace, :position
    attr_accessible :chart, :chart_id, :parent, :parent_id, :type, :name, :namespace, :position, :start_date, :end_date, :as => :admin
    attr_readonly   :chart, :parent

    belongs_to :chart
    has_many :accounts, :through => :chart
    has_many :rulesets, :through => :chart

    has_many :unscoped_events, :through => :rulesets, :source => :events, :uniq => true
    has_many :unscoped_flags, :through => :rulesets, :source => :flags, :uniq => true
    has_many :unscoped_transactions, :through => :accounts, :source => :transactions, :uniq => true
    has_many :unscoped_amounts, :through => :accounts, :source => :amounts, :uniq => true, :extend => ESA::Associations::AmountsExtension

    belongs_to :parent, :class_name => "Context"
    has_many :subcontexts, :class_name => "Context", :foreign_key => "parent_id", :dependent => :destroy

    after_initialize :initialize_defaults, :initialize_filters
    before_validation :update_name, :update_position
    validates_presence_of :chart, :name
    validate :validate_parent
    before_save :enforce_persistence_rule

    scope :roots, lambda { where(parent_id: nil) }
    scope :subs,  lambda { where("esa_contexts.parent_id is not null") }

    scope :fresh, lambda { where("`esa_contexts`.`freshness` > ?", ESA.configuration.context_freshness_threshold.ago) }
    scope :stale, lambda { where("`esa_contexts`.`freshness` IS NULL OR `esa_contexts`.`freshness` <= ?", ESA.configuration.context_freshness_threshold.ago) }

    def is_root?
      self.parent_id.nil?
    end

    def is_subcontext?
      self.parent_id.present?
    end

    def events
      self.apply(self.unscoped_events)
    end

    def flags
      self.apply(self.unscoped_flags)
    end

    def transactions
      self.apply(self.unscoped_transactions)
    end

    def amounts
      self.apply(self.unscoped_amounts)
    end

    def apply(relation)
      self.effective_contexts.inject(relation) do |r,context|
        context.inject_filters(r)
      end
    end

    def last_transaction_time
      if defined? @last_transaction_time
        @last_transaction_time
      else
        @last_transaction_time = self.transactions.maximum(:created_at)
      end
    end

    def is_fresh?(time=Time.zone.now)
      self.freshness.present? and (self.freshness + ESA.configuration.context_freshness_threshold) > time
    end

    def has_subcontext_namespaces?(*namespaces)
      (namespaces.flatten.compact - subcontext_namespaces).none?
    end

    def check_freshness(depth=0, options = {})
      if self.is_update_needed? or not has_subcontext_namespaces?(options[:namespace])
        self.update!(options)
      else
        self.update_freshness_timestamp!
      end

      if depth > 0 and self.last_transaction_time.present?
        self.subcontexts.each do |sub|
          if sub.freshness.nil? or sub.freshness <= self.last_transaction_time
            sub.check_freshness(depth - 1)
          end
        end
      end
    end

    def is_update_needed?
      if self.freshness.present?
        if self.last_transaction_time.present?
          self.freshness <= self.last_transaction_time
        else
          false
        end
      else
        true
      end
    end

    def update!(options = {})
      self.freshness = self.next_freshness_timestamp

      ESA.configuration.context_checkers.each do |checker|
        if checker.respond_to? :check
          checker.check(self, options)
        end
      end

      self.update_name
      self.save if self.can_be_persisted?
    end

    def update_freshness_timestamp!
      self.freshness = self.next_freshness_timestamp
      self.save if self.can_be_persisted?
    end

    def next_freshness_timestamp
      if self.parent.present?
        [self.freshness, self.parent.try(:freshness)].compact.max
      else
        Time.zone.now
      end
    end

    def subcontext_namespaces
      self.subcontexts.pluck(:namespace).compact.uniq
    end

    def effective_contexts
      self.parents_and_self
    end

    def effective_path
      self.effective_contexts.map{|ctx| ctx.namespace || ""}
    end

    def effective_start_date
      self.effective_contexts.map(&:start_date).compact.max
    end

    def effective_end_date
      self.effective_contexts.map(&:end_date).compact.min
    end

    def opening_context
      if self.effective_start_date.present?
        end_date = self.effective_start_date - 1.day
        Contexts::OpenCloseContext.new(chart: self.chart, parent: self, end_date: end_date, namespace: 'opening')
      else
        Contexts::EmptyContext.new(chart: self.chart, parent: self, namespace: 'opening')
      end
    end

    def closing_context
      if self.effective_end_date.present?
        Contexts::OpenCloseContext.new(chart: self.chart, parent: self, end_date: self.effective_end_date, namespace: 'closing')
      else
        Contexts::OpenCloseContext.new(chart: self.chart, parent: self, namespace: 'closing')
      end
    end

    def change_total
      if self.debits_total.present? and self.credits_total.present?
        self.debits_total - self.credits_total
      else
        nil
      end
    end

    def can_be_persisted?
      self.type.present?
    end

    protected

    def validate_parent
      if self.parent == self
        errors[:parent] = "cannot self-reference, that would create a loop"
      end
    end

    def initialize_defaults
      self.chart ||= self.parent.chart if self.chart_id.nil? and not self.parent_id.nil?
      self.namespace ||= self.default_namespace
      self.position ||= self.default_position
    end

    def default_namespace
      (self.type || self.class.name).demodulize.underscore.gsub(/_context$/, '')
    end

    def update_name
      self.name = self.default_name
    end

    def default_name
      if self.type.nil?
        self.chart.name unless self.chart.nil?
      else
        "#{self.type.demodulize} \##{self.id}"
      end
    end

    def update_position
      self.position = self.default_position
    end

    def default_position
      nil
    end

    def initialize_filters
      @filters = []
    end

    def inject_filters(relation)
      @filters.select{|f| f.is_a? Proc}.
      inject(relation) do |r,filter|
        filter.call(r)
      end
    end

    def parents_and_self
      contexts = [self]
      while contexts.last.parent_id.present? and
            not contexts.last.parent_id.in? contexts.map(&:id) and
            contexts.count < 16 do
        # found a valid parent
        contexts << contexts.last.parent
      end
      contexts.reverse
    end

    def enforce_persistence_rule
      if not self.can_be_persisted?
        raise "#{self.class.name} objects are not intended to be persisted"
      end
    end
  end
end