code-mancers/invoicing

View on GitHub
lib/invoicing/taxable.rb

Summary

Maintainability
B
6 hrs
Test Coverage
# encoding: utf-8

require "active_support/concern"

module Invoicing
  # = Computation of tax on prices
  #
  # This module provides a general-purpose framework for calculating tax. Its most common application
  # will probably be for computing VAT/sales tax on the price of your product, but since you can easily
  # attach your own tax computation logic, it can apply in a broad variety of different situations.
  #
  # Computing the tax on a price may be as simple as multiplying it with a constant factor, but in most
  # cases it will be more complicated. The tax rate may change over time (see +TimeDependent+), may vary
  # depending on the customer currently viewing the page (and the country in which they are located),
  # and may depend on properties of the object to which the price belongs. This module does not implement
  # any specific computation, but makes easy to implement specific tax regimes with minimal code duplication.
  #
  # == Using taxable attributes in a model
  #
  # If you have a model object (a subclass of <tt>ActiveRecord::Base</tt>) with a monetary quantity
  # (such as a price) in one or more of its database columns, you can declare that those columns/attributes
  # are taxable, for example:
  #
  #   class MyProduct < ActiveRecord::Base
  #     acts_as_taxable :normal_price, :promotion_price, :tax_logic => Invoicing::Countries::UK::VAT.new
  #   end
  #
  # In the taxable columns (+normal_price+ and +promotion_price+ in this example) you <b>must always
  # store values excluding tax</b>. The option <tt>:tax_logic</tt> is mandatory, and you must give it
  # an instance of a 'tax logic' object; you may use one of the tax logic implementations provided with
  # this framework, or write your own. See below for details of what a tax logic object needs to do.
  #
  # Your database table should also contain a column +currency+, in which you store the ISO 4217
  # three-letter upper-case code identifying the currency of the monetary amounts in the same table row.
  # If your currency code column has a name other than +currency+, you need to specify the name of that
  # column to +acts_as_taxable+ using the <tt>:currency => '...'</tt> option.
  #
  # For each attribute which you declare as taxable, several new methods are generated on your model class:
  #
  # <tt><attr></tt>::                        Returns the amount of money excluding tax, as stored in the database,
  #                                          subject to the model object's currency rounding conventions.
  # <tt><attr>=</tt>::                       Assigns a new value (exclusive of tax) to the attribute.
  # <tt><attr>_taxed</tt>::                  Returns the amount of money including tax, as computed by the tax
  #                                          logic, subject to the model object's currency rounding conventions.
  # <tt><attr>_taxed=</tt>::                 Assigns a new value (including tax) to the attribute.
  # <tt><attr>_tax_rounding_error</tt>::     Returns a number indicating how much the tax-inclusive value of the
  #                                          attribute has changed as a result of currency rounding. See the section
  #                                          'currency rounding errors' below. +nil+ if the +_taxed=+ attribute
  #                                          has not been assigned.
  # <tt><attr>_tax_info</tt>::               Returns a short string to inform a user about the tax status of
  #                                          the value returned by <tt><attr>_taxed</tt>; this could be
  #                                          "inc. VAT", for example, if the +_taxed+ attribute includes VAT.
  # <tt><attr>_tax_details</tt>::            Like +_tax_info+, but a longer string for places in the user
  #                                          interface where more space is available. For example, "including
  #                                          VAT at 15%".
  # <tt><attr>_with_tax_info</tt>::          Convenience method for views: returns the attribute value including
  #                                          tax, formatted as a human-friendly currency string in UTF-8, with
  #                                          the return value of +_tax_info+ appended. For example,
  #                                          "AU$1,234.00 inc. GST".
  # <tt><attr>_with_tax_details</tt>::       Like +_with_tax_info+, but using +_tax_details+. For example,
  #                                          "AU$1,234.00 including 10% Goods and Services Tax".
  # <tt><attr>_taxed_before_type_cast</tt>:: Returns any value which you assign to <tt><attr>_taxed=</tt> without
  #                                          converting it first. This means you to can use +_taxed+ attributes as
  #                                          fields in Rails forms and get the expected behaviour of form validation.
  #
  # +acts_as_currency+ is automatically called for all attributes given to +acts_as_taxable+, as well as all
  # generated <tt><attr>_taxed</tt> attributes. This means you get automatic currency-specific rounding
  # behaviour as documented in the +CurrencyValue+ module, and you get two additional methods for free:
  # <tt><attr>_formatted</tt> and <tt><attr>_taxed_formatted</tt>, which return the untaxed and taxed amounts
  # respectively, formatted as a nice human-friendly string.
  #
  # The +Taxable+ module automatically converts between taxed and untaxed attributes. This works as you would
  # expect: you can assign to a taxed attribute and immediately read from an untaxed attribute, or vice versa.
  # When you store the object, only the untaxed value is written to the database. That way, if the tax rate
  # changes or you open your business to overseas customers, nothing changes in your database.
  #
  # == Using taxable attributes in views and forms
  #
  # The tax logic object allows you to have one single place in your application where you declare which products
  # are seen by which customers at which tax rate. For example, if you are a VAT registered business in an EU
  # country, you always charge VAT at your home country's rate to customers within your home country; however,
  # to a customer in a different EU country you do not charge any VAT if you have received a valid VAT registration
  # number from them. You see that this logic can easily become quite complicated. This complexity should be
  # encapsulated entirely within the tax logic object, and not require any changes to your views or controllers if
  # at all possible.
  #
  # The way to achieve this is to <b>always use the +_taxed+ attributes in views and forms</b>, unless you have a
  # very good reason not to. The value returned by <tt><attr>_taxed</tt>, and the value you assign to
  # <tt><attr>_taxed=</tt>, do not necessarily have to include tax; for a given customer and product, the tax may
  # be zero-rated or not applicable, in which case their numeric value will be the same as the untaxed attributes.
  # The attributes are called +_taxed+ because they may be taxed, not because they necessarily always are. It is
  # up to the tax logic to decide whether to return the same number, or one modified to include tax.
  #
  # The purpose of the +_tax_info+ and +_tax_details+ methods is to clarify the tax status of a given number to the
  # user; if the number returned by the +_taxed+ attribute does not contain tax for whatever reason, +_tax_info+ for
  # the same attribute should say so.
  #
  # Using these attributes, views can be kept very simple:
  #
  #   <h1>Products</h1>
  #   <table>
  #     <tr>
  #       <th>Name</th>
  #       <th>Price</th>
  #     </tr>
  #   <% for product in @products %>
  #     <tr>
  #       <td><%=h product.name %></td>
  #       <td><%=h product.price_with_tax_info %></td>                                # e.g. "$25.80 (inc. tax)"
  #     </tr>
  #   <% end %>
  #   </table>
  #
  #   <h1>New product</h1>
  #   <% form_for(@product) do |f| %>
  #     <%= f.error_messages %>
  #     <p>
  #       <%= f.label :name, "Product name:" %><br />
  #       <%= f.text_field :name %>
  #     </p>
  #     <p>
  #       <%= f.label :price_taxed, "Price #{h(@product.price_tax_info)}:" %><br />   # e.g. "Price (inc. tax):"
  #       <%= f.text_field :price_taxed %>
  #     </p>
  #   <% end %>
  #
  # If this page is viewed by a user who shouldn't be shown tax, the numbers in the output will be different,
  # and it might say "excl. tax" instead of "inc. tax"; but none of that clutters the view. Moreover, any price
  # typed into the form will of course be converted as appropriate for that user. This is important, for
  # example, in an auction scenario, where you may have taxed and untaxed bidders bidding in the same
  # auction; their input and output is personalised depending on their account information, but for
  # purposes of determining the winning bidder, all bidders are automatically normalised to the untaxed
  # value of their bids.
  #
  # == Tax logic objects
  #
  # A tax logic object is an instance of a class with the following structure:
  #
  #   class MyTaxLogic
  #     def apply_tax(params)
  #       # Called to convert a value without tax into a value with tax, as applicable. params is a hash:
  #       #   :model_object => The model object whose attribute is being converted
  #       #   :attribute    => The name of the attribute (without '_taxed' suffix) being converted
  #       #   :value        => The untaxed value of the attribute as a BigDecimal
  #       # Should return a number greater than or equal to the input value. Don't worry about rounding --
  #       # CurrencyValue deals with that.
  #     end
  #
  #     def remove_tax(params)
  #       # Does the reverse of apply_tax -- converts a value with tax into a value without tax. The params
  #       # hash has the same format. First applying tax and then removing it again should always result in the
  #       # starting value (for the the same object and the same environment -- it may depend on time,
  #       # global variables, etc).
  #     end
  #
  #     def tax_info(params, *args)
  #       # Should return a short string to explain to users which operation has been performed by apply_tax
  #       # (e.g. if apply_tax has added VAT, the string could be "inc. VAT"). The params hash is the same as
  #       # given to apply_tax. Additional parameters are optional; if any arguments are passed to a call to
  #       # model_object.<attr>_tax_info then they are passed on here.
  #     end
  #
  #     def tax_details(params, *args)
  #       # Like tax_info, but should return a longer string for use in user interface elements which are less
  #       # limited in size.
  #     end
  #
  #     def mixin_methods
  #       # Optionally you can define a method mixin_methods which returns a list of method names which should
  #       # be included in classes which use this tax logic. Methods defined here become instance methods of
  #       # model objects with acts_as_taxable attributes. For example:
  #       [:some_other_method]
  #     end
  #
  #     def some_other_method(params, *args)
  #       # some_other_method was named by mixin_methods to be included in model objects. For example, if the
  #       # class MyProduct uses MyTaxLogic, then MyProduct.find(1).some_other_method(:foo, 'bar') will
  #       # translate into MyTaxLogic#some_other_method({:model_object => MyProduct.find(1)}, :foo, 'bar').
  #       # The model object on which the method is called is passed under the key :model_object in the
  #       # params hash, and all other arguments to the method are simply passed on.
  #     end
  #   end
  #
  #
  # == Currency rounding errors
  #
  # Both the taxed and the untaxed value of an attribute are currency values, and so they must both be rounded
  # to the accuracy which is conventional for the currency in use (see the discussion of precision and rounding
  # in the +CurrencyValue+ module). If we are always storing untaxed values and outputting taxed values to the
  # user, this is not a problem. However, if we allow users to input taxed values (like in the form example
  # above), something curious may happen: The input value has its tax removed, is rounded to the currency's
  # conventional precision and stored in the database in untaxed form; then later it is loaded, tax is added
  # again, it is again rounded to the currency's conventional precision, and displayed to the user. If the
  # rounding steps have rounded the number upwards twice, or downwards twice, it may happen that the value
  # displayed to the user differs slightly from the one they originally entered.
  #
  # We believe that storing untaxed values and performing currency rounding are the right things to do, and this
  # apparent rounding error is a natural consequence. This module therefore tries to deal with the error
  # elegantly: If you assign a value to a taxed attribute and immediately read it again, it will return the
  # same value as if it had been stored and loaded again (i.e. the number you read has been rounded twice --
  # make sure the currency code has been assigned to the object beforehand, so that the +CurrencyValue+ module
  # knows which precision to apply).
  #
  # Moreover, after assigning a value to a <tt><attr>_taxed=</tt> attribute, the <tt><attr>_tax_rounding_error</tt>
  # method can tell you whether and by how much the value has changed as a result of removing and re-applying
  # tax. A negative number indicates that the converted amount is less than the input; a positive number indicates
  # that it is more than entered by the user; and zero means that there was no difference.
  #
  module Taxable
    extend ActiveSupport::Concern

    module ActMethods
      # Declares that one or more attributes on this model object store monetary values to which tax may be
      # applied. Takes one or more attribute names, followed by an options hash:
      # <tt>:tax_logic</tt>:: Object with instance methods apply_tax, remove_tax, tax_info and tax_details
      #                       as documented in the +Taxable+ module. Required.
      # <tt>:currency</tt>::  The name of the attribute/database column which stores the ISO 4217 currency
      #                       code for the monetary amounts in this model object. Required if the column
      #                       is not called +currency+.
      # +acts_as_taxable+ implies +acts_as_currency_value+ with the same options. See the +Taxable+ for details.
      def acts_as_taxable(*args)
        Invoicing::ClassInfo.acts_as(Invoicing::Taxable, self, args)

        attrs = taxable_class_info.new_args.map{|a| a.to_s }
        currency_attrs = attrs + attrs.map{|attr| "#{attr}_taxed"}
        currency_opts = taxable_class_info.all_options.update({:conversion_input => :convert_taxable_value})
        acts_as_currency_value(currency_attrs, currency_opts)

        attrs.each {|attr| generate_attr_taxable_methods(attr) }

        if tax_logic = taxable_class_info.all_options[:tax_logic]
          other_methods = (tax_logic.respond_to?(:mixin_methods) ? tax_logic.mixin_methods : []) || []
          other_methods.each {|method_name| generate_attr_taxable_other_method(method_name.to_s) }
        else
          raise ArgumentError, 'You must specify a :tax_logic option for acts_as_taxable'
        end
      end
    end

    # If +write_attribute+ is called on a taxable attribute, we note whether the taxed or the untaxed
    # version contains the latest correct value. We don't do the conversion immediately in case the tax
    # logic requires the value of another attribute (which may be assigned later) to do its calculation.
    def write_attribute(attribute, value) #:nodoc:
      attribute = attribute.to_s
      attr_regex = taxable_class_info.all_args.map{|a| a.to_s }.join('|')
      @taxed_or_untaxed ||= {}
      @taxed_attributes ||= {}

      if attribute =~ /^(#{attr_regex})$/
        @taxed_or_untaxed[attribute] = :untaxed
        @taxed_attributes[attribute] = nil
      elsif attribute =~ /^(#{attr_regex})_taxed$/
        if (ActiveRecord::VERSION::MAJOR == 4 && ActiveRecord::VERSION::MINOR >= 2) || ActiveRecord::VERSION::MAJOR == 5
          @attributes.write_cast_value(attribute, value)
        else
          @attributes[attribute] = value
        end

        @taxed_or_untaxed[$1] = :taxed
        @taxed_attributes[$1] = value
      end

      super
    end

    # Called internally to convert between taxed and untaxed values. You shouldn't usually need to
    # call this method from elsewhere.
    def convert_taxable_value(attr) #:nodoc:
      attr = attr.to_s
      attr_without_suffix = attr.sub(/(_taxed)$/, '')
      to_status = ($1 == '_taxed') ? :taxed : :untaxed

      @taxed_or_untaxed ||= {}
      from_status = @taxed_or_untaxed[attr_without_suffix] || :untaxed # taxed or untaxed most recently assigned?

      attr_to_read = attr_without_suffix
      attr_to_read += '_taxed' if from_status == :taxed

      if from_status == :taxed && to_status == :taxed
        # Special case: remove tax, apply rounding errors, apply tax again, apply rounding errors again.
        write_attribute(attr_without_suffix, send(attr_without_suffix))
        send(attr)
      else
        taxable_class_info.convert(self, attr_without_suffix, read_attribute(attr_to_read), from_status, to_status)
      end
    end

    protected :write_attribute, :convert_taxable_value


    module ClassMethods #:nodoc:
      # Generate additional accessor method for attribute with getter +method_name+.
      def generate_attr_taxable_methods(method_name) #:nodoc:

        define_method("#{method_name}_tax_rounding_error") do
          original_value = read_attribute("#{method_name}_taxed")
          return nil if original_value.nil? # Can only have a rounding error if the taxed attr was assigned

          original_value = BigDecimal(original_value.to_s)
          converted_value = send("#{method_name}_taxed")

          return nil if converted_value.nil?
          converted_value - original_value
        end

        define_method("#{method_name}_tax_info") do |*args|
          tax_logic = taxable_class_info.all_options[:tax_logic]
          tax_logic.tax_info({:model_object => self, :attribute => method_name, :value => send(method_name)}, *args)
        end

        define_method("#{method_name}_tax_details") do |*args|
          tax_logic = taxable_class_info.all_options[:tax_logic]
          tax_logic.tax_details({:model_object => self, :attribute => method_name, :value => send(method_name)}, *args)
        end

        define_method("#{method_name}_with_tax_info") do |*args|
          amount = send("#{method_name}_taxed_formatted")
          tax_info = send("#{method_name}_tax_info").to_s
          tax_info.blank? ? amount : "#{amount} #{tax_info}"
        end

        define_method("#{method_name}_with_tax_details") do |*args|
          amount = send("#{method_name}_taxed_formatted")
          tax_details = send("#{method_name}_tax_details").to_s
          tax_details.blank? ? amount : "#{amount} #{tax_details}"
        end

        define_method("#{method_name}_taxed_before_type_cast") do
          @taxed_attributes ||= {}
          @taxed_attributes[method_name] ||
            read_attribute_before_type_cast("#{method_name}_taxed") ||
            send("#{method_name}_taxed")
        end
      end

      # Generate a proxy method called +method_name+ which is forwarded to the +tax_logic+ object.
      def generate_attr_taxable_other_method(method_name) #:nodoc:
        define_method(method_name) do |*args|
          tax_logic = taxable_class_info.all_options[:tax_logic]
          tax_logic.send(method_name, {:model_object => self}, *args)
        end
      end

      private :generate_attr_taxable_methods, :generate_attr_taxable_other_method
    end # module ClassMethods


    class ClassInfo < Invoicing::ClassInfo::Base #:nodoc:
      # Performs the conversion between taxed and untaxed values. Arguments +from_status+ and
      # +to_status+ must each be either <tt>:taxed</tt> or <tt>:untaxed</tt>.
      def convert(object, attr_without_suffix, value, from_status, to_status)
        return nil if value.nil?
        value = BigDecimal(value.to_s)
        return value if from_status == to_status

        if to_status == :taxed
          all_options[:tax_logic].apply_tax({:model_object => object, :attribute => attr_without_suffix, :value => value})
        else
          all_options[:tax_logic].remove_tax({:model_object => object, :attribute => attr_without_suffix, :value => value})
        end
      end
    end

  end
end