code-mancers/invoicing

View on GitHub
lib/invoicing/time_dependent.rb

Summary

Maintainability
A
1 hr
Test Coverage
require "active_support/concern"

module Invoicing
  # == Time-dependent value objects
  #
  # This module implements the notion of a value (or a set of values) which may change at
  # certain points in time, and for which it is important to have a full history of values
  # for every point in time. It is used in the invoicing framework as basis for tax rates,
  # prices, commissions etc.
  #
  # === Background
  #
  # To illustrate the need for this tool, consider for example the case of a tax rate. Say
  # the rate is currently 10%, and in a naive implementation you simply store the value
  # <tt>0.1</tt> in a constant. Whenever you need to calculate tax on a price, you multiply
  # the price with the constant, and store the result together with the price in the database.
  # Then, one day the government decides to increase the tax rate to 12%. On the day the
  # change takes effect, you change the value of the constant to <tt>0.12</tt>.
  #
  # This naive implementation has a number of problems, which are addressed by this module:
  # * With a constant, you have no way of informing users what a price will be after an
  #   upcoming tax change. Using +TimeDependent+ allows you to query the value on any date
  #   in the past or future, and show it to users as appropriate. You also gain the ability
  #   to process back-dated or future-dated transactions if this should be necessary.
  # * With a constant, you have no explicit information in your database informing you which
  #   rate was applied for a particular tax calculation. You may be able to infer the rate
  #   from the prices you store, but this may not be enough in cases where there is additional
  #   metadata attached to tax rates (e.g. if there are different tax rates for different
  #   types of product). With +TimeDependent+ you can have an explicit reference to the tax
  #   object which formed the basis of a calculation, giving you a much better audit trail.
  # * If there are different tax categories (e.g. a reduced rate for products of type A, and
  #   a higher rate for type B), the government may not only change the rates themselves, but
  #   also decide to reclassify product X as type B rather than type A. In any case you will
  #   need to store the type of each of your products; however, +TimeDependent+ tries to
  #   minimize the amount of reclassifying you need to do, should it become necessary.
  #
  # == Data Structure
  #
  # +TimeDependent+ objects are special ActiveRecord::Base objects. One database table is used,
  # and each row in that table represents the value (e.g. the tax rate or the price) during
  # a particular period of time. If there are multiple different values at the same time (e.g.
  # a reduced tax rate and a higher rate), each of these is also represented as a separate
  # row. That way you can refer to a +TimeDependent+ object from another model object (such as
  # storing the tax category for a product), and refer simultaneously to the type of tax
  # applicable for this product and the period for which this classification is valid.
  #
  # If a rate change is announced, it <b>important that the actual values in the table
  # are not changed</b> in order to preserve historical information. Instead, add another
  # row (or several rows), taking effect on the appropriate date. However, it is usually
  # not necessary to update your other model objects to refer to these new rows; instead,
  # each +TimeDependent+ object which expires has a reference to the new +TimeDependent+
  # objects which replaces it. +TimeDependent+ provides methods for finding the current (or
  # future) rate by following this chain of replacements.
  #
  # === Example
  #
  # To illustrate, take as example the rate of VAT (Value Added Tax) in the United Kingdom.
  # The main tax rate was at 17.5% until 1 December 2008, when it was changed to 15%.
  # On 1 January 2010 it is due to be changed back to 17.5%. At the same time, there are a
  # reduced rates of 5% and 0% on certain goods; while the main rate was changed, the
  # reduced rates stayed unchanged.
  #
  # The table of +TimeDependent+ records will look something like this:
  #
  #   +----+-------+---------------+------------+---------------------+---------------------+----------------+
  #   | id | value | description   | is_default | valid_from          | valid_until         | replaced_by_id |
  #   +----+-------+---------------+------------+---------------------+---------------------+----------------+
  #   |  1 | 0.175 | Standard rate |          1 | 1991-04-01 00:00:00 | 2008-12-01 00:00:00 |              4 |
  #   |  2 |  0.05 | Reduced rate  |          0 | 1991-04-01 00:00:00 | NULL                |           NULL |
  #   |  3 |   0.0 | Zero rate     |          0 | 1991-04-01 00:00:00 | NULL                |           NULL |
  #   |  4 |  0.15 | Standard rate |          1 | 2008-12-01 00:00:00 | 2010-01-01 00:00:00 |              5 |
  #   |  5 | 0.175 | Standard rate |          1 | 2010-01-01 00:00:00 | NULL                |           NULL |
  #   +----+-------+---------------+------------+---------------------+---------------------+----------------+
  #
  # Graphically, this may be illustrated as:
  #
  #             1991-04-01             2008-12-01             2010-01-01
  #                      :                      :                      :
  #   Standard rate: 17.5% -----------------> 15% ---------------> 17.5% ----------------->
  #                      :                      :                      :
  #   Zero rate:        0% --------------------------------------------------------------->
  #                      :                      :                      :
  #   Reduced rate:     5% --------------------------------------------------------------->
  #
  # It is a deliberate choice that a +TimeDependent+ object references its successor, and not
  # its predecessor. This is so that you can classify your items based on the current
  # classification, and be sure that if the current rate expires there is an unambiguous
  # replacement for it. On the other hand, it is usually not important to know what the rate
  # for a particular item would have been at some point in the past.
  #
  # Now consider a slightly more complicated (fictional) example, in which a UK court rules
  # that teacakes have been incorrectly classified for VAT purposes, namely that they should
  # have been zero-rated while actually they had been standard-rated. The court also decides
  # that all sales of teacakes before 1 Dec 2008 should maintain their old standard-rated status,
  # while sales from 1 Dec 2008 onwards should be zero-rated.
  #
  # Assume you have an online shop in which you sell teacakes and other goods (both standard-rated
  # and zero-rated). You can handle this reclassification (in addition to the standard VAT rate
  # change above) as follows:
  #
  #             1991-04-01             2008-12-01             2010-01-01
  #                      :                      :                      :
  #   Standard rate: 17.5% -----------------> 15% ---------------> 17.5% ----------------->
  #                      :                      :                      :
  #   Teacakes:      17.5% ------------.        :                      :
  #                      :              \_      :                      :
  #   Zero rate:        0% ---------------+->  0% ---------------------------------------->
  #                      :                      :                      :
  #   Reduced rate:     5% --------------------------------------------------------------->
  #
  # Then you just need to update the teacake products in your database, which previously referred
  # to the 17.5% object valid from 1991-04-01, to refer to the special teacake rate. None of the
  # other products need to be modified. This way, the teacakes will automatically switch to the 0%
  # rate on 2008-12-01. If you add any new teacake products to the database after December 2008, you
  # can refer either to the teacake rate or to the new 0% rate which takes effect on 2008-12-01;
  # it won't make any difference.
  #
  # == Usage notes
  #
  # This implementation is designed for tables with a small number of rows (no more than a few
  # dozen) and very infrequent changes. To reduce database load, it caches model objects very
  # aggressively; <b>you will need to restart your Ruby interpreter after making a change to
  # the data</b> as the cache is not cleared between requests. This is ok because you shouldn't
  # be lightheartedly modifying +TimeDependent+ data anyway; a database migration as part of an
  # explicitly deployed release is probably the best way of introducing a rate change
  # (that way you can also check it all looks correct on your staging server before making the
  # rate change public).
  #
  # A model object using +TimeDependent+ must inherit from ActiveRecord::Base and must have
  # at least the following columns (although columns may have different names, if declared to
  # +acts_as_time_dependent+):
  # * <tt>id</tt> -- An integer primary key
  # * <tt>valid_from</tt> -- A column of type <tt>datetime</tt>, which must not be <tt>NULL</tt>.
  #   It contains the moment at which the rate takes effect. The oldest <tt>valid_from</tt> dates
  #   in the table should be in the past by a safe margin.
  # * <tt>valid_until</tt> -- A column of type <tt>datetime</tt>, which contains the moment from
  #   which the rate is no longer valid. It may be <tt>NULL</tt>, in which case the the rate is
  #   taken to be "valid until further notice". If it is not <tt>NULL</tt>, it must contain a
  #   date strictly later than <tt>valid_from</tt>.
  # * <tt>replaced_by_id</tt> -- An integer, foreign key reference to the <tt>id</tt> column in
  #   this same table. If <tt>valid_until</tt> is <tt>NULL</tt>, <tt>replaced_by_id</tt> must also
  #   be <tt>NULL</tt>. If <tt>valid_until</tt> is non-<tt>NULL</tt>, <tt>replaced_by_id</tt> may
  #   or may not be <tt>NULL</tt>; if it refers to a replacement object, the <tt>valid_from</tt>
  #   value of that replacement object must be equal to the <tt>valid_until</tt> value of this
  #   object.
  #
  # Optionally, the table may have further columns:
  # * <tt>value</tt> -- The actual (usually numeric) value for which we're going to all this
  #   effort, e.g. a tax rate percentage or a price in some currency unit.
  # * <tt>is_default</tt> -- A boolean column indicating whether or not this object should be
  #   considered a default during its period of validity. This may be useful if there are several
  #   different rates in effect at the same time (such as standard, reduced and zero rate in the
  #   example above). If this column is used, there should be exactly one default rate at any
  #   given point in time, otherwise results are undefined.
  #
  # Apart from these requirements, a +TimeDependent+ object is a normal model object, and you may
  # give it whatever extra metadata you want, and make references to it from any other model object.
  module TimeDependent
    extend ActiveSupport::Concern

    module ActMethods
      # Identifies the current model object as a +TimeDependent+ object, and creates all the
      # necessary methods.
      #
      # Accepts options in a hash, all of which are optional:
      # * <tt>id</tt> -- Alternative name for the <tt>id</tt> column
      # * <tt>valid_from</tt> -- Alternative name for the <tt>valid_from</tt> column
      # * <tt>valid_until</tt> -- Alternative name for the <tt>valid_until</tt> column
      # * <tt>replaced_by_id</tt> -- Alternative name for the <tt>replaced_by_id</tt> column
      # * <tt>value</tt> -- Alternative name for the <tt>value</tt> column
      # * <tt>is_default</tt> -- Alternative name for the <tt>is_default</tt> column
      #
      # Example:
      #
      #   class CommissionRate < ActiveRecord::Base
      #     acts_as_time_dependent :value => :rate
      #     belongs_to :referral_program
      #     named_scope :for_referral_program, lambda { |p| { :conditions => { :referral_program_id => p.id } } }
      #   end
      #
      #   reseller_program = ReferralProgram.find(1)
      #   current_commission = CommissionRate.for_referral_program(reseller_program).default_record_now
      #   puts "Earn #{current_commission.rate} per cent commission as a reseller..."
      #
      #   changes = current_commission.changes_until(1.year.from_now)
      #   for next_commission in changes
      #     message = next_commission.nil? ? "Discontinued as of" : "Changing to #{next_commission.rate} per cent on"
      #     puts "#{message} #{current_commission.valid_until.strftime('%d %b %Y')}!"
      #     current_commission = next_commission
      #   end
      #
      def acts_as_time_dependent(*args)
        Invoicing::ClassInfo.acts_as(Invoicing::TimeDependent, self, args)

        # Create replaced_by association if it doesn't exist yet
        replaced_by_id = time_dependent_class_info.method(:replaced_by_id)
        unless respond_to? :replaced_by
          belongs_to :replaced_by, class_name: name, foreign_key: replaced_by_id
        end

        # Create value_at and value_now method aliases
        value_method = time_dependent_class_info.method(:value).to_s
        if value_method != 'value'
          alias_method(value_method + '_at',  :value_at)
          alias_method(value_method + '_now', :value_now)
          class_eval <<-ALIAS
            class << self
              alias_method('default_#{value_method}_at',  :default_value_at)
              alias_method('default_#{value_method}_now', :default_value_now)
            end
          ALIAS
        end
      end # acts_as_time_dependent
    end # module ActMethods


    module ClassMethods
      # Returns a list of records which are valid at some point during a particular date/time
      # range. If there is a change of rate during this time interval, and one rate replaces
      # another, then only the earliest element of each replacement chain is returned
      # (because we can unambiguously convert from an earlier rate to a later one, but
      # not necessarily in reverse).
      #
      # The date range must not be empty (i.e. +not_after+ must be later than +not_before+,
      # not the same time or earlier). If you need the records which are valid at one
      # particular point in time, use +valid_records_at+.
      #
      # A typical application for this method would be where you want to offer users the
      # ability to choose from a selection of rates, including ones which are not yet
      # valid but will become valid within the next month, for example.
      def valid_records_during(not_before, not_after)
        info = time_dependent_class_info

        # List of all records whose validity period intersects the selected period
        valid_records = all.select do |record|
          valid_from  = info.get(record, :valid_from)
          valid_until = info.get(record, :valid_until)
          has_taken_effect = (valid_from < not_after) # N.B. less than
          not_yet_expired  = (valid_until == nil) || (valid_until > not_before)
          has_taken_effect && not_yet_expired
        end

        # Select only those which do not have a predecessor which is also valid
        valid_records.select do |record|
          record.predecessors.empty? || (valid_records & record.predecessors).empty?
        end
      end

      # Returns the list of all records which are valid at one particular point in time.
      # If you need to consider a period of time rather than a point in time, use
      # +valid_records_during+.
      def valid_records_at(point_in_time)
        info = time_dependent_class_info
        all.select do |record|
          valid_from  = info.get(record, :valid_from)
          valid_until = info.get(record, :valid_until)
          has_taken_effect = (valid_from <= point_in_time) # N.B. less than or equals
          not_yet_expired  = (valid_until == nil) || (valid_until > point_in_time)
          has_taken_effect && not_yet_expired
        end
      end

      # Returns the default record which is valid at a particular point in time.
      # If there is no record marked as default, nil is returned; if there are
      # multiple records marked as default, results are undefined.
      # This method only works if the model objects have an +is_default+ column.
      def default_record_at(point_in_time)
        info = time_dependent_class_info
        valid_records_at(point_in_time).select{|record| info.get(record, :is_default)}.first
      end

      # Returns the default record which is valid at the current moment.
      def default_record_now
        default_record_at(Time.now)
      end

      # Finds the default record for a particular +point_in_time+ (using +default_record_at+),
      # then returns the value of that record's +value+ column. If +value+ was renamed to
      # +another_method_name+ (option to +acts_as_time_dependent+), then
      # +default_another_method_name_at+ is defined as an alias for +default_value_at+.
      def default_value_at(point_in_time)
        time_dependent_class_info.get(default_record_at(point_in_time), :value)
      end

      # Finds the current default record (like +default_record_now+),
      # then returns the value of that record's +value+ column. If +value+ was renamed to
      # +another_method_name+ (option to +acts_as_time_dependent+), then
      # +default_another_method_name_now+ is defined as an alias for +default_value_now+.
      def default_value_now
        default_value_at(Time.now)
      end

    end # module ClassMethods

    # Returns a list of objects of the same type as this object, which refer to this object
    # through their +replaced_by_id+ values. In other words, this method returns all records
    # which are direct predecessors of the current record in the replacement chain.
    def predecessors
      time_dependent_class_info.predecessors(self)
    end

    # Translates this record into its replacement for a given point in time, if necessary/possible.
    #
    # * If this record is still valid at the given date/time, this method just returns self.
    # * If this record is no longer valid at the given date/time, the record which has been
    #   marked as this rate's replacement for the given point in time is returned.
    # * If this record has expired and there is no valid replacement, nil is returned.
    # * On the other hand, if the given date is at a time before this record becomes valid,
    #   we try to follow the chain of +predecessors+ records. If there is an unambiguous predecessor
    #   record which is valid at the given point in time, it is returned; otherwise nil is returned.
    def record_at(point_in_time)
      valid_from  = time_dependent_class_info.get(self, :valid_from)
      valid_until = time_dependent_class_info.get(self, :valid_until)

      if valid_from > point_in_time
        (predecessors.size == 1) ? predecessors[0].record_at(point_in_time) : nil
      elsif valid_until.nil? || (valid_until > point_in_time)
        self
      elsif replaced_by.nil?
        nil
      else
        replaced_by.record_at(point_in_time)
      end
    end

    # Returns self if this record is currently valid, otherwise its past or future replacement
    # (see +record_at+). If there is no valid replacement, nil is returned.
    def record_now
      record_at Time.now
    end

    # Finds this record's replacement for a given point in time (see +record_at+), then returns
    # the value in its +value+ column. If +value+ was renamed to +another_method_name+ (option to
    # +acts_as_time_dependent+), then +another_method_name_at+ is defined as an alias for +value_at+.
    def value_at(point_in_time)
      time_dependent_class_info.get(record_at(point_in_time), :value)
    end

    # Returns +value_at+ for the current date/time. If +value+ was renamed to +another_method_name+
    # (option to +acts_as_time_dependent+), then +another_method_name_now+ is defined as an alias for
    # +value_now+.
    def value_now
      value_at Time.now
    end

    # Examines the replacement chain from this record into the future, during the period
    # starting with this record's +valid_from+ and ending at +point_in_time+.
    # If this record stays valid until after +point_in_time+, an empty list is returned.
    # Otherwise the sequence of replacement records is returned in the list. If a record
    # expires before +point_in_time+ and without replacement, a +nil+ element is inserted
    # as the last element of the list.
    def changes_until(point_in_time)
      info = time_dependent_class_info
      changes = []
      record = self
      while !record.nil?
        valid_until = info.get(record, :valid_until)
        break if valid_until.nil? || (valid_until > point_in_time)
        record = record.replaced_by
        changes << record
      end
      changes
    end


    # Stores state in the ActiveRecord class object
    class ClassInfo < Invoicing::ClassInfo::Base #:nodoc:
      def predecessors(record)
        # @predecessors is a hash of an ID pointing to the list of all objects which have that ID
        # as replaced_by_id value
        @predecessors ||= fetch_predecessors
        @predecessors[get(record, :id)] || []
      end

      def fetch_predecessors
        _predecessors = {}
        for record in model_class.all
          id = get(record, :replaced_by_id)
          unless id.nil?
            _predecessors[id] ||= []
            _predecessors[id] << record
          end
        end
        _predecessors
      end
    end # class ClassInfo
  end # module TimeDependent
end