├── .gitignore ├── HACKING.md ├── History.txt ├── LICENSE ├── Manifest.txt ├── README.rdoc ├── Rakefile ├── config └── database.yml ├── lib ├── invoicing.rb └── invoicing │ ├── cached_record.rb │ ├── class_info.rb │ ├── connection_adapter_ext.rb │ ├── countries │ └── uk.rb │ ├── currency_value.rb │ ├── find_subclasses.rb │ ├── ledger_item.rb │ ├── ledger_item │ ├── render_html.rb │ └── render_ubl.rb │ ├── line_item.rb │ ├── price.rb │ ├── tax_rate.rb │ ├── taxable.rb │ ├── time_dependent.rb │ └── version.rb ├── release.sh ├── script ├── console ├── destroy └── generate ├── tasks └── rcov.rake └── test ├── cached_record_test.rb ├── class_info_test.rb ├── connection_adapter_ext_test.rb ├── currency_value_test.rb ├── find_subclasses_test.rb ├── fixtures ├── README ├── cached_record.sql ├── class_info.sql ├── currency_value.sql ├── find_subclasses.sql ├── ledger_item.sql ├── line_item.sql ├── price.sql ├── tax_rate.sql ├── taxable.sql └── time_dependent.sql ├── ledger_item_test.rb ├── line_item_test.rb ├── models ├── README ├── test_subclass_in_another_file.rb └── test_subclass_not_in_database.rb ├── price_test.rb ├── ref-output ├── creditnote3.html ├── creditnote3.xml ├── invoice1.html ├── invoice1.xml ├── invoice2.html ├── invoice2.xml └── invoice_null.html ├── render_html_test.rb ├── render_ubl_test.rb ├── setup.rb ├── tax_rate_test.rb ├── taxable_test.rb ├── test_helper.rb └── time_dependent_test.rb /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | doc 3 | pkg 4 | .loadpath 5 | .project 6 | .settings 7 | .DS_Store 8 | -------------------------------------------------------------------------------- /HACKING.md: -------------------------------------------------------------------------------- 1 | Developing the invoicing gem 2 | ============================ 3 | 4 | For development, you need up-to-date versions of the following gems (`sudo gem install `): 5 | 6 | * rake 7 | * activerecord 8 | * mysql (if you use MySQL) 9 | * pg (if you use PostgreSQL) 10 | * newgem 11 | * flexmock 12 | 13 | Fork the invoicing gem on GitHub, then clone it to your machine: 14 | 15 | $ git clone git@github.com:YOUR-GITHUB-USERNAME/invoicing.git 16 | 17 | 18 | MySQL 19 | ----- 20 | 21 | You need to set up a test database. You can modify the settings for the test database in 22 | `config/database.yml`, but if you stick with the defaults, you can set up the database like this: 23 | 24 | $ echo "create database ept_invoicing_test" | mysql -uroot 25 | $ echo "grant all on ept_invoicing_test.* to 'build'@'localhost'" | mysql -uroot 26 | 27 | Then run the tests by typing `rake`. 28 | 29 | 30 | PostgreSQL 31 | ---------- 32 | 33 | To set up the test database, run something like the following as root: 34 | 35 | # su -c 'createuser -SDRP invoicing' postgres # (enter "password" as password) 36 | # su -c 'createdb -O invoicing invoicing_test' postgres 37 | 38 | Then run the tests by typing: 39 | 40 | $ DATABASE=postgresql rake 41 | -------------------------------------------------------------------------------- /History.txt: -------------------------------------------------------------------------------- 1 | == 0.2.1 2 | 3 | * 2 minor enhancements: 4 | * Added option to LedgerItem#account_summary which enables inclusion of open/pending 5 | ledger items in the summary 6 | * Improved robustness and documentation for test suite 7 | * 2 bugfixes: 8 | * Compatibility with recent versions of ActiveRecord 9 | * Cleaned up unnecessary gem dependencies (mocha, uuid) 10 | 11 | == 0.2.0 2009-04-20 12 | 13 | * 4 major enhancements: 14 | * New associated gem invoicing_generator for generating an invoicing component in a Rails project 15 | (script/generate invoicing_ledger) 16 | * Generated controller and views for rendering statements and ledger 17 | * Comes with a nice default stylesheet out of the box 18 | * More flexible formatting of currency values 19 | * 1 bugfix: 20 | * Accidental overwriting of total_amount value on payment LedgerItems without LineItems 21 | 22 | == 0.1.0 2009-02-10 23 | 24 | * 2 major enhancements: 25 | * Core API is now usable 26 | * RCov reports 100% test coverage 27 | 28 | == 0.0.1 2009-01-05 29 | 30 | * 1 major enhancement: 31 | * Initial public release 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 Martin Kleppmann 2 | Copyright (c) 2009 Ept Computing Limited 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | this software and associated documentation files (the "Software"), to deal in 6 | the Software without restriction, including without limitation the rights to 7 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 8 | of the Software, and to permit persons to whom the Software is furnished to do 9 | so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /Manifest.txt: -------------------------------------------------------------------------------- 1 | History.txt 2 | LICENSE 3 | Manifest.txt 4 | README.rdoc 5 | Rakefile 6 | lib/invoicing.rb 7 | lib/invoicing/cached_record.rb 8 | lib/invoicing/class_info.rb 9 | lib/invoicing/connection_adapter_ext.rb 10 | lib/invoicing/countries/uk.rb 11 | lib/invoicing/currency_value.rb 12 | lib/invoicing/find_subclasses.rb 13 | lib/invoicing/ledger_item.rb 14 | lib/invoicing/ledger_item/render_html.rb 15 | lib/invoicing/ledger_item/render_ubl.rb 16 | lib/invoicing/line_item.rb 17 | lib/invoicing/price.rb 18 | lib/invoicing/tax_rate.rb 19 | lib/invoicing/taxable.rb 20 | lib/invoicing/time_dependent.rb 21 | lib/invoicing/version.rb 22 | script/console 23 | script/destroy 24 | script/generate 25 | tasks/rcov.rake 26 | test/cached_record_test.rb 27 | test/class_info_test.rb 28 | test/connection_adapter_ext_test.rb 29 | test/currency_value_test.rb 30 | test/find_subclasses_test.rb 31 | test/fixtures/README 32 | test/fixtures/cached_record.sql 33 | test/fixtures/class_info.sql 34 | test/fixtures/currency_value.sql 35 | test/fixtures/find_subclasses.sql 36 | test/fixtures/ledger_item.sql 37 | test/fixtures/line_item.sql 38 | test/fixtures/price.sql 39 | test/fixtures/tax_rate.sql 40 | test/fixtures/taxable.sql 41 | test/fixtures/time_dependent.sql 42 | test/ledger_item_test.rb 43 | test/line_item_test.rb 44 | test/models/README 45 | test/models/test_subclass_in_another_file.rb 46 | test/models/test_subclass_not_in_database.rb 47 | test/price_test.rb 48 | test/ref-output/creditnote3.html 49 | test/ref-output/creditnote3.xml 50 | test/ref-output/invoice1.html 51 | test/ref-output/invoice1.xml 52 | test/ref-output/invoice2.html 53 | test/ref-output/invoice2.xml 54 | test/ref-output/invoice_null.html 55 | test/render_html_test.rb 56 | test/render_ubl_test.rb 57 | test/setup.rb 58 | test/tax_rate_test.rb 59 | test/taxable_test.rb 60 | test/test_helper.rb 61 | test/time_dependent_test.rb 62 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | For a more up-to-date version see {code-mancers/invoicing}[https://github.com/code-mancers/invoicing]! 2 | 3 | = Ruby Invoicing Framework 4 | 5 | * {Website}[http://ept.github.com/invoicing/] 6 | * {API Docs}[http://rubydoc.info/github/ept/invoicing/frames/] 7 | * {Code}[http://github.com/ept/invoicing/] 8 | 9 | == DESCRIPTION 10 | 11 | This is a framework for generating and displaying invoices (ideal for 12 | commercial Rails apps). It allows for flexible business logic; provides tools 13 | for tax handling, commission calculation etc. It aims to be both 14 | developer-friendly and accountant-friendly. 15 | 16 | The Ruby Invoicing Framework is based on 17 | {ActiveRecord}[http://api.rubyonrails.org/classes/ActiveRecord/Base.html]. 18 | 19 | Please see {the website}[http://ept.github.com/invoicing/] for an introduction 20 | to using Invoicing, and check the 21 | {API reference}[http://invoicing.rubyforge.org/doc/] for in-depth details. 22 | 23 | If you're interested in contributing to the invoicing gem itself, please see the file 24 | {HACKING.md}[http://github.com/ept/invoicing/blob/master/HACKING.md]. 25 | 26 | == FEATURES 27 | 28 | * TODO 29 | 30 | == REQUIREMENTS 31 | 32 | * ActiveRecord >= 2.1 33 | * Only MySQL and PostgreSQL databases are currently supported 34 | 35 | == INSTALL 36 | 37 | sudo gem install invoicing 38 | 39 | == STATUS 40 | 41 | So far, the Ruby Invoicing Framework has been tested with ActiveRecord 2.2.2, 42 | MySQL 5.0.67 and PostgreSQL 8.3.5. We will be testing it across a wider 43 | variety of versions soon. 44 | 45 | == CREDITS 46 | 47 | The Ruby invoicing framework originated as part of the website 48 | {Bid for Wine}[http://www.bidforwine.co.uk], developed by Patrick Dietrich, 49 | Conrad Irwin, Michael Arnold and Martin Kleppmann for Ept Computing Ltd. 50 | It was extracted from the Bid for Wine codebase and substantially extended 51 | by Martin Kleppmann. 52 | 53 | == LICENSE 54 | 55 | Copyright (c) 2009 Martin Kleppmann, Ept Computing Limited. 56 | 57 | This gem is made publicly available under the terms of the MIT license. 58 | See LICENSE and/or COPYING for details. 59 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | %w[rubygems rake rake/clean fileutils newgem rubigen hoe].each { |f| require f } 2 | require File.dirname(__FILE__) + '/lib/invoicing' 3 | 4 | # Hoe calls Ruby with the "-w" set by default; unfortunately, ActiveRecord (at version 2.2.2 5 | # at least) causes a lot of warnings internally, by no fault of our own, which clutters 6 | # the output. Comment out the following four lines to see those warnings. 7 | class Hoe 8 | RUBY_FLAGS = ENV['RUBY_FLAGS'] || "-I#{%w(lib .).join(File::PATH_SEPARATOR)}" + 9 | ((defined?(RUBY_DEBUG) && RUBY_DEBUG) ? " #{RUBY_DEBUG}" : '') 10 | end 11 | 12 | # Generate all the Rake tasks 13 | # Run 'rake -T' to see list of generated tasks (from gem root directory) 14 | $hoe = Hoe.spec 'invoicing' do |p| 15 | p.version = Invoicing::VERSION 16 | p.developer 'Martin Kleppmann', 'rubyforge@eptcomputing.com' 17 | 18 | p.summary = p.paragraphs_of('README.rdoc', 3).join 19 | p.description = p.paragraphs_of('README.rdoc', 3..5).join("\n\n") 20 | p.changes = p.paragraphs_of("History.txt", 0..1).join("\n\n") 21 | p.post_install_message = 'PostInstall.txt' 22 | p.rubyforge_name = p.name 23 | 24 | p.extra_deps = [ 25 | ['activerecord', '>= 2.1.0'], 26 | ['builder', '>= 2.0'] 27 | ] 28 | p.extra_dev_deps = [ 29 | ['newgem', ">= #{::Newgem::VERSION}"] 30 | #['invoicing_generator', "= #{Invoicing::VERSION}"] - causes a circular dependency in rubygems < 1.2 31 | ] 32 | 33 | p.test_globs = %w[test/*_test.rb] # do not include test/models/*.rb 34 | p.clean_globs |= %w[**/.DS_Store tmp *.log coverage] 35 | p.rsync_args = '-av --delete --ignore-errors' 36 | p.remote_rdoc_dir = 'doc' 37 | end 38 | 39 | require 'newgem/tasks' # load /tasks/*.rake 40 | Dir['tasks/**/*.rake'].each { |t| load t } 41 | 42 | # Tasks to run by default 43 | # task :default => [:spec, :features] 44 | -------------------------------------------------------------------------------- /config/database.yml: -------------------------------------------------------------------------------- 1 | # Details of the database connection to use for running unit tests. 2 | test: 3 | adapter: mysql 4 | encoding: utf8 5 | # Follow RCR standard for database name 6 | database: ept_invoicing_test 7 | host: localhost 8 | username: build 9 | password: 10 | -------------------------------------------------------------------------------- /lib/invoicing.rb: -------------------------------------------------------------------------------- 1 | $:.unshift(File.dirname(__FILE__)) unless 2 | $:.include?(File.dirname(__FILE__)) || $:.include?(File.expand_path(File.dirname(__FILE__))) 3 | 4 | require 'active_record' 5 | 6 | require 'invoicing/class_info' # load first because other modules depend on this 7 | Dir.glob(File.join(File.dirname(__FILE__), 'invoicing/**/*.rb')).sort.each {|f| require f } 8 | 9 | # Mix all modules Invoicing::*::ActMethods into ActiveRecord::Base as class methods 10 | Invoicing.constants.map{|c| Invoicing.const_get(c) }.select{|m| m.is_a?(Module) && m.const_defined?('ActMethods') }.each{ 11 | |m| ActiveRecord::Base.send(:extend, m.const_get('ActMethods')) 12 | } 13 | -------------------------------------------------------------------------------- /lib/invoicing/cached_record.rb: -------------------------------------------------------------------------------- 1 | module Invoicing 2 | # == Aggressive ActiveRecord cache 3 | # 4 | # This module implements a cache of +ActiveRecord+ objects. It is suitable for database 5 | # tables with a small number of rows (no more than a few dozen is recommended) which 6 | # change very infrequently. The contents of the table is loaded into memory when the 7 | # class is first created; to clear the cache you must call +clear_cache+ or 8 | # restart the Ruby interpreter. It is recommended that if you need to change the 9 | # data in this table, you do so in a database migration, and apply that migration as 10 | # part of a release deployment. 11 | # 12 | # The cache works as a simple identity map: it has a hash where the key is the primary 13 | # key of each model object and the value is the model object itself. +ActiveRecord+ 14 | # methods are overridden so that if +find+ is called with one or more IDs, the object(s) 15 | # are returned from cache; if +find+ is called with more complex conditions, the usual 16 | # database mechanisms are used and the cache is ignored. Note that this does not 17 | # guarantee that the same ID value will always map to the same model object instance; 18 | # it just reduces the number of database queries. 19 | # 20 | # To activate +CachedRecord+, call +acts_as_cached_record+ in the scope of an 21 | # ActiveRecord::Base class. 22 | module CachedRecord 23 | 24 | module ActMethods 25 | # Call +acts_as_cached_record+ on an ActiveRecord::Base class to declare 26 | # that objects of this class should be cached using +CachedRecord+. 27 | # 28 | # Accepts options in a hash, all of which are optional: 29 | # * +id+ -- If the primary key of this model is not +id+, declare the method name 30 | # of the primary key. 31 | def acts_as_cached_record(*args) 32 | Invoicing::ClassInfo.acts_as(Invoicing::CachedRecord, self, args) 33 | end 34 | end 35 | 36 | module ClassMethods 37 | # This method overrides the default ActiveRecord::Base.find_from_ids (which is called 38 | # from ActiveRecord::Base.find) with caching behaviour. +find+ is also used by 39 | # +ActiveRecord+ when evaluating associations; therefore if another model object refers to 40 | # a cached record by its ID, calling the getter of that association should result in a cache hit. 41 | # 42 | # FIXME: Currently +options+ is ignored -- we should do something more useful with it 43 | # to ensure CachedRecord behaviour is fully compatible with +ActiveRecord+. 44 | def find_from_ids(ids, options) 45 | expects_array = ids.first.kind_of?(Array) 46 | return ids.first if expects_array && ids.first.empty? 47 | 48 | ids = ids.flatten.compact.uniq 49 | 50 | case ids.size 51 | when 0 52 | raise ::ActiveRecord::RecordNotFound, "Couldn't find #{name} without an ID" 53 | when 1 54 | result = cached_record_class_info.find_one(ids.first, options) 55 | expects_array ? [ result ] : result 56 | else 57 | cached_record_class_info.find_some(ids, options) 58 | end 59 | end 60 | 61 | # Returns a list of all objects of this class. Like ActiveRecord::Base.find(:all) 62 | # but coming from the cache. 63 | def cached_record_list 64 | cached_record_class_info.list 65 | end 66 | 67 | # Reloads the cached objects from the database. 68 | def reload_cache 69 | cached_record_class_info.reload_cache 70 | end 71 | end # module ClassMethods 72 | 73 | 74 | # Stores state in the ActiveRecord class object, including the cache -- 75 | # a hash which maps ID to model object for all objects of this model object type 76 | class ClassInfo < Invoicing::ClassInfo::Base #:nodoc: 77 | def initialize(model_class, previous_info, args) 78 | super 79 | reload_cache 80 | end 81 | 82 | def reload_cache 83 | @cache = {} 84 | model_class.find(:all).each {|obj| @cache[get(obj, :id)] = obj } 85 | end 86 | 87 | # Returns one object from the cache, given its ID. 88 | def find_one(id, options) 89 | if result = @cache[id] 90 | result 91 | else 92 | raise ::ActiveRecord::RecordNotFound, "Couldn't find #{model_class.name} with ID=#{id}" 93 | end 94 | end 95 | 96 | # Returns a list of objects from the cache, given a list of IDs. 97 | def find_some(ids, options) 98 | ids.map{|id| find_one(id, options) } 99 | end 100 | 101 | # Returns a list of all objects in the cache. 102 | def list 103 | @cache.values 104 | end 105 | end # class ClassInfo 106 | end # module CachedRecord 107 | end 108 | -------------------------------------------------------------------------------- /lib/invoicing/class_info.rb: -------------------------------------------------------------------------------- 1 | module Invoicing 2 | # This module is intended for use only internally within this framework. It implements 3 | # a pattern needed in several other modules: an +acts_as_something_or_other+ method can 4 | # be called within the scope of an +ActiveRecord+ class, given a number of arguments; 5 | # including options which define how columns are renamed in a given model object. 6 | # The information from these arguments needs to be stored in a class variable for later 7 | # use in instances of that class. It must be possible to call the +acts_as_+ method 8 | # multiple times, combining the arguments from the various calls, to make the whole thing 9 | # look nicely declarative. Subclasses should inherit +acts_as_+ arguments from their 10 | # superclass, but should be able to override them with their own values. 11 | # 12 | # This pattern assumes a particular module structure, like the following: 13 | # 14 | # module MyNamespace # you may use arbitrarily nested modules for namespacing (optional) 15 | # module Teleporter # the name of this module defines auto-generated method names 16 | # module ActMethods 17 | # def acts_as_teleporter(*args) # should be called "acts_as_#{module_name.underscore}" 18 | # Invoicing::ClassInfo.acts_as(MyNamespace::Teleporter, self, args) 19 | # end 20 | # end 21 | # 22 | # def transmogrify_the_instance # will become an instance method of the class on which the 23 | # info = teleporter_class_info # acts_as_ method is called. 24 | # info.do_transmogrify 25 | # end 26 | # 27 | # module ClassMethods 28 | # def transmogrify_the_class # will become a class method of the class on which the 29 | # info = teleporter_class_info # acts_as_ method is called. 30 | # info.do_transmogrify 31 | # end 32 | # end 33 | # 34 | # class ClassInfo < Invoicing::ClassInfo::Base 35 | # def do_transmogrify 36 | # case all_options[:transmogrification] 37 | # when :total then "Transmogrified by #{all_args.first}" 38 | # end 39 | # end 40 | # end 41 | # end 42 | # end 43 | # 44 | # ActiveRecord::Base.send(:extend, MyNamespace::Teleporter::ActMethods) 45 | # 46 | # 47 | # +ClassInfo+ is used to store and process the arguments passed to the +acts_as_teleporter+ method 48 | # when it is called in the scope of an +ActiveRecord+ model class. Finally, the feature defined by 49 | # the +Teleporter+ module above can be used like this: 50 | # 51 | # class Teleporter < ActiveRecord::Base 52 | # acts_as_teleporter 'Zoom2020', :transmogrification => :total 53 | # end 54 | # 55 | # Teleporter.transmogrify_the_class # both return "Transmogrified by Zoom2020" 56 | # Teleporter.find(42).transmogrify_the_instance 57 | module ClassInfo 58 | 59 | # Provides the main implementation pattern for an +acts_as_+ method. See the example above 60 | # for usage. 61 | # +source_module+:: The module object which is using the +ClassInfo+ pattern 62 | # +calling_class+:: The class in whose scope the +acts_as_+ method was called 63 | # +args+:: The array of arguments (including options hash) to the +acts_as_+ method 64 | def self.acts_as(source_module, calling_class, args) 65 | # The name by which the particular module using ClassInfo is known 66 | module_name = source_module.name.split('::').last.underscore 67 | class_info_method = "#{module_name}_class_info" 68 | 69 | previous_info = if calling_class.private_instance_methods(true).include?(class_info_method) 70 | # acts_as has been called before on the same class, or a superclass 71 | calling_class.send(class_info_method) 72 | else 73 | # acts_as is being called for the first time -- do the mixins! 74 | calling_class.send(:include, source_module) 75 | calling_class.send(:extend, source_module.const_get('ClassMethods')) if source_module.constants.include? 'ClassMethods' 76 | nil # no previous_info 77 | end 78 | 79 | # Instantiate the ClassInfo::Base subclass and assign it to an instance variable in calling_class 80 | class_info_class = source_module.const_get('ClassInfo') 81 | class_info = class_info_class.new(calling_class, previous_info, args) 82 | calling_class.instance_variable_set("@#{class_info_method}", class_info) 83 | 84 | # Define a getter class method on calling_class through which the ClassInfo::Base 85 | # instance can be accessed. 86 | calling_class.class_eval <<-CLASSEVAL 87 | class << self 88 | def #{class_info_method} 89 | if superclass.private_instance_methods(true).include?("#{class_info_method}") 90 | @#{class_info_method} ||= superclass.send("#{class_info_method}") 91 | end 92 | @#{class_info_method} 93 | end 94 | private "#{class_info_method}" 95 | end 96 | CLASSEVAL 97 | 98 | # For convenience, also define an instance method which does the same as the class method 99 | calling_class.class_eval do 100 | define_method class_info_method do 101 | self.class.send(class_info_method) 102 | end 103 | private class_info_method 104 | end 105 | end 106 | 107 | 108 | # Base class for +ClassInfo+ objects, from which you need to derive a subclass in each module where 109 | # you want to use +ClassInfo+. An instance of a ClassInfo::Base subclass is created every 110 | # time an +acts_as_+ method is called, and that instance can be accessed through the 111 | # +my_module_name_class_info+ method on the class which called +acts_as_my_module_name+. 112 | class Base 113 | # The class on which the +acts_as_+ method was called 114 | attr_reader :model_class 115 | 116 | # The ClassInfo::Base instance created by the last +acts_as_+ method 117 | # call on the same class (or its superclass); +nil+ if this is the first call. 118 | attr_reader :previous_info 119 | 120 | # The list of arguments passed to the current +acts_as_+ method call (excluding the final options hash) 121 | attr_reader :current_args 122 | 123 | # Union of +current_args+ and previous_info.all_args 124 | attr_reader :all_args 125 | 126 | # self.all_args - previous_info.all_args 127 | attr_reader :new_args 128 | 129 | # The options hash passed to the current +acts_as_+ method call 130 | attr_reader :current_options 131 | 132 | # Hash of options with symbolized keys, with +option_defaults+ overridden by +previous_info+ options, 133 | # in turn overridden by +current_options+. 134 | attr_reader :all_options 135 | 136 | # Initialises a ClassInfo::Base instance and parses arguments. 137 | # If subclasses override +initialize+ they should call +super+. 138 | # +model_class+:: The class on which the +acts_as+ method was called 139 | # +previous_info+:: The ClassInfo::Base instance created by the last +acts_as_+ method 140 | # call on the same class (or its superclass); +nil+ if this is the first call. 141 | # +args+:: Array of arguments given to the +acts_as_+ method when it was invoked. 142 | # 143 | # If the last element of +args+ is a hash, it is used as an options array. All other elements 144 | # of +args+ are concatenated into an array, +uniq+ed and flattened. (They could be a list of symbols 145 | # representing method names, for example.) 146 | def initialize(model_class, previous_info, args) 147 | @model_class = model_class 148 | @previous_info = previous_info 149 | 150 | @current_options = args.extract_options!.symbolize_keys 151 | @all_options = (@previous_info.nil? ? option_defaults : @previous_info.all_options).clone 152 | @all_options.update(@current_options) 153 | 154 | @all_args = @new_args = @current_args = args.flatten.uniq 155 | unless @previous_info.nil? 156 | @all_args = (@previous_info.all_args + @all_args).uniq 157 | @new_args = @all_args - previous_info.all_args 158 | end 159 | end 160 | 161 | # Override this method to return a hash of default option values. 162 | def option_defaults 163 | {} 164 | end 165 | 166 | # If there is an option with the given key, returns the associated value; otherwise returns 167 | # the key. This is useful for mapping method names to their renamed equivalents through options. 168 | def method(name) 169 | name = name.to_sym 170 | (all_options[name] || name).to_s 171 | end 172 | 173 | # Returns the value returned by calling +method_name+ (renamed through options using +method+) 174 | # on +object+. Returns +nil+ if +object+ is +nil+ or +object+ does not respond to that method. 175 | def get(object, method_name) 176 | meth = method(method_name) 177 | (object.nil? || !object.respond_to?(meth)) ? nil : object.send(meth) 178 | end 179 | 180 | # Assigns +new_value+ to method_name= (renamed through options using +method+) 181 | # on +object+. +method_name+ should not include the equals sign. 182 | def set(object, method_name, new_value) 183 | object.send("#{method(method_name)}=", new_value) unless object.nil? 184 | end 185 | end 186 | end 187 | end -------------------------------------------------------------------------------- /lib/invoicing/connection_adapter_ext.rb: -------------------------------------------------------------------------------- 1 | module Invoicing 2 | # Extensions specific to certain database adapters. Currently only MySQL and PostgreSQL are 3 | # supported. 4 | class ConnectionAdapterExt 5 | 6 | # Creates a database-specific SQL fragment for evaluating a three-legged conditional function 7 | # in a query. 8 | def self.conditional_function(condition, value_if_true, value_if_false) 9 | case ActiveRecord::Base.connection.adapter_name 10 | when "MySQL" 11 | "IF(#{condition}, #{value_if_true}, #{value_if_false})" 12 | when "PostgreSQL" 13 | "CASE WHEN #{condition} THEN #{value_if_true} ELSE #{value_if_false} END" 14 | else 15 | raise "Database adapter #{ActiveRecord::Base.connection.adapter_name} not supported by invoicing gem" 16 | end 17 | end 18 | 19 | # Suppose A has_many B, and you want to select all As, counting for each A how many 20 | # Bs it has. In MySQL you can just say: 21 | # SELECT A.*, COUNT(B.id) AS number_of_bs FROM A LEFT JOIN B on A.id = B.a_id GROUP BY A.id 22 | # PostgreSQL, however, doesn't like you selecting a column from A if that column is neither 23 | # in the GROUP BY clause nor wrapped in an aggregation function (even though it is 24 | # implicitly grouped by through the fact that A.id is unique per row). Therefore 25 | # for PostgreSQL, we need to explicitly list all of A's columns in the GROUP BY 26 | # clause. 27 | # 28 | # This method takes a model class (a subclass of ActiveRecord::Base) and returns 29 | # a string suitable to be used as the contents of the GROUP BY clause. 30 | def self.group_by_all_columns(model_class) 31 | case ActiveRecord::Base.connection.adapter_name 32 | when "MySQL" 33 | model_class.quoted_table_name + "." + 34 | ActiveRecord::Base.connection.quote_column_name(model_class.primary_key) 35 | when "PostgreSQL" 36 | model_class.column_names.map{ |column| 37 | model_class.quoted_table_name + "." + ActiveRecord::Base.connection.quote_column_name(column) 38 | }.join(', ') 39 | else 40 | raise "Database adapter #{ActiveRecord::Base.connection.adapter_name} not supported by invoicing gem" 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/invoicing/countries/uk.rb: -------------------------------------------------------------------------------- 1 | module Invoicing 2 | module Countries 3 | module UK 4 | # Extremely simplistic implementation of UK VAT. This needs to be fixed. 5 | class VAT 6 | def tax_rate(params) 7 | params[:model_object].send(:tax_rate) 8 | end 9 | 10 | def tax_factor(params) 11 | BigDecimal('1') + tax_rate(params).rate 12 | end 13 | 14 | def tax_percent(params) 15 | BigDecimal('100') * tax_rate(params).rate 16 | end 17 | 18 | def apply_tax(params) 19 | params[:value] * tax_factor(params) 20 | end 21 | 22 | def remove_tax(params) 23 | params[:value] / tax_factor(params) 24 | end 25 | 26 | def tax_info(params) 27 | "(inc. VAT)" 28 | end 29 | 30 | def tax_details(params) 31 | "(including VAT at #{tax_percent(params).to_s}%)" 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/invoicing/currency_value.rb: -------------------------------------------------------------------------------- 1 | module Invoicing 2 | # = Input and output of monetary values 3 | # 4 | # This module simplifies model objects which need to store monetary values. It automatically takes care 5 | # of currency rounding conventions and formatting values for output. 6 | # 7 | # == General notes on currency precision and rounding 8 | # 9 | # It is important to deal carefully with rounding errors in accounts. If the figures don't add up exactly, 10 | # you may have to pay for expensive accountant hours while they try to find out where the missing pennies or 11 | # cents have gone -- better to avoid this trouble from the start. Because of this, it is strongly recommended 12 | # that you use fixed-point or decimal datatypes to store any sort of currency amounts, never floating-point 13 | # numbers. 14 | # 15 | # Keep in mind that not all currencies subdivide their main unit into 100 smaller units; storing four digits 16 | # after the decimal point should be enough to allow you to expand into other currencies in future. Also leave 17 | # enough headroom in case you ever need to use an inflated currency. For example, 18 | # if you are using MySQL, decimal(20,4) may be a good choice for all your columns which store 19 | # monetary amounts. The extra few bytes aren't going to cost you anything. 20 | # 21 | # On the other hand, it doesn't usually make sense to store monetary values with a higher precision than is 22 | # conventional for a particular currency (usually this is related to the value of the smallest coin in 23 | # circulation, but conventions may differ). For example, if your currency rounds to two decimal places, then 24 | # you should also round every monetary amount to two decimal places before storing it. If you store values 25 | # at a higher precision than you display, your numbers may appear to not add up correctly when you present 26 | # them to users. Fortunately, this module automatically performs currency-specific rounding for you. 27 | # 28 | # == Using +acts_as_currency_value+ 29 | # 30 | # This module simplifies model objects which need to store monetary values, by automatically taking care 31 | # of currency rounding and formatting conventions. In a typical set-up, every model object which has one or 32 | # more attributes storing monetary amounts (a price, a fee, a tax amount, a payment value, etc.) should also 33 | # have a +currency+ column, which stores the ISO 4217 three-letter upper-case code identifying the currency. 34 | # Annotate your model class with +acts_as_currency_value+, passing it a list of attribute names which store 35 | # monetary amounts. If you refuse to store a +currency+ attribute, you may instead specify a default currency 36 | # by passing a :currency_code => CODE option to +acts_as_currency_value+, but this is not recommended: 37 | # even if you are only using one currency now, you may well expand into other currencies later. It is not 38 | # possible to have multiple different currencies in the same model object. 39 | # 40 | # The +CurrencyValue+ module knows how to handle a set of default currencies (see +CURRENCIES+ below). If your 41 | # currency is not supported in the way you want, you can extend/modify the hash yourself (please also send us 42 | # a patch so that we can extend our list of inbuilt currencies): 43 | # Invoicing::CurrencyValue::CURRENCIES['HKD'] = {:symbol => 'HK$', :round => 0.10, :digits => 2} 44 | # This specifies that the Hong Kong Dollar should be displayed using the 'HK$' symbol and two digits after the 45 | # decimal point, but should always be rounded to the nearest 10 cents since the 10 cent coin is the smallest 46 | # in circulation (therefore the second digit after the decimal point will always be zero). 47 | # 48 | # When that is done, you can use the model object normally, and rounding will occur automatically: 49 | # invoice.currency = 'HKD' 50 | # invoice.tax_amount = invoice.net_amount * TaxRates.default_rate_now # 1234.56789 51 | # invoice.tax_amount == BigDecimal('1234.6') # true - rounded to nearest 0.01 52 | # 53 | # Moreover, you can just append +_formatted+ to your attribute name and get the value formatted for including 54 | # in your views: 55 | # invoice.tax_amount_formatted # 'HK$1,234.60' 56 | # The string returned by a +_formatted+ method is UTF-8 encoded -- remember most currency symbols (except $) 57 | # are outside basic 7-bit ASCII. 58 | module CurrencyValue 59 | 60 | # Data about currencies, indexed by ISO 4217 code. (Currently a very short list, in need of extending.) 61 | # The values are hashes, in which the following keys are recognised: 62 | # :round:: Smallest unit of the currency in normal use, to which values are rounded. Default is 0.01. 63 | # :symbol:: Symbol or string usually used to denote the currency. Encoded as UTF-8. Default is ISO 4217 code. 64 | # :suffix:: +true+ if the currency symbol appears after the number, +false+ if it appears before. Default +false+. 65 | CURRENCIES = { 66 | 'EUR' => {:symbol => "\xE2\x82\xAC"}, # Euro 67 | 'GBP' => {:symbol => "\xC2\xA3"}, # Pound Sterling 68 | 'USD' => {:symbol => "$"}, # United States Dollar 69 | 'CAD' => {:symbol => "$"}, # Canadian Dollar 70 | 'AUD' => {:symbol => "$"}, # Australian Dollar 71 | 'CNY' => {:symbol => "\xE5\x85\x83", :suffix => true}, # Chinese Yuan (RMB) 72 | 'INR' => {:symbol => "\xE2\x82\xA8"}, # Indian Rupee 73 | 'JPY' => {:symbol => "\xC2\xA5", :round => 1} # Japanese Yen 74 | } 75 | 76 | module ActMethods 77 | # Declares that the current model object has columns storing monetary amounts. Pass those attribute 78 | # names to +acts_as_currency_value+. By default, we try to find an attribute or method called +currency+, 79 | # which stores the 3-letter ISO 4217 currency code for a record; if that attribute has a different name, 80 | # specify the name using the :currency option. For example: 81 | # 82 | # class Price < ActiveRecord::Base 83 | # validates_numericality_of :net_amount, :tax_amount 84 | # validates_inclusion_of :currency_code, %w( USD GBP EUR JPY ) 85 | # acts_as_currency_value :net_amount, :tax_amount, :currency => :currency_code 86 | # end 87 | # 88 | # You may also specify the :value_for_formatting option, passing it the name of a method on 89 | # your model object. That method will be called when a CurrencyValue method with +_formatted+ suffix 90 | # is called, and allows you to modify the numerical value before it is formatted into a string. An 91 | # options hash is also passed. This can be useful, for example, if a value is stored positive but you 92 | # want to display it as negative in certain circumstances depending on the view: 93 | # 94 | # class LedgerItem < ActiveRecord::Base 95 | # acts_as_ledger_item 96 | # acts_as_currency_value :total_amount, :tax_amount, :value_for_formatting => :value_for_formatting 97 | # 98 | # def value_for_formatting(value, options={}) 99 | # value *= -1 if options[:debit] == :negative && debit?(options[:self_id]) 100 | # value *= -1 if options[:credit] == :negative && !debit?(options[:self_id]) 101 | # value 102 | # end 103 | # end 104 | # 105 | # invoice = Invoice.find(1) 106 | # invoice.total_amount_formatted :debit => :negative, :self_id => invoice.sender_id 107 | # # => '$25.00' 108 | # invoice.total_amount_formatted :debit => :negative, :self_id => invoice.recipient_id 109 | # # => '-$25.00' 110 | # 111 | # (The example above is actually a real part of +LedgerItem+.) 112 | def acts_as_currency_value(*args) 113 | Invoicing::ClassInfo.acts_as(Invoicing::CurrencyValue, self, args) 114 | 115 | # Register callback if this is the first time acts_as_currency_value has been called 116 | before_save :write_back_currency_values if currency_value_class_info.previous_info.nil? 117 | end 118 | end 119 | 120 | # Format a numeric monetary value into a human-readable string, in the currency of the 121 | # current model object. 122 | def format_currency_value(value, options={}) 123 | currency_value_class_info.format_value(self, value, options) 124 | end 125 | 126 | 127 | # Called automatically via +before_save+. Writes the result of converting +CurrencyValue+ attributes 128 | # back to the actual attributes, so that they are saved in the database. (This doesn't happen in 129 | # +convert_currency_values+ to avoid losing the +_before_type_cast+ attribute values.) 130 | def write_back_currency_values 131 | currency_value_class_info.all_args.each {|attr| write_attribute(attr, send(attr)) } 132 | end 133 | 134 | protected :write_back_currency_values 135 | 136 | 137 | # Encapsulates the methods for formatting currency values in a human-friendly way. 138 | # These methods do not depend on ActiveRecord and can thus also be called externally. 139 | module Formatter 140 | class << self 141 | 142 | # Given the three-letter ISO 4217 code of a currency, returns a hash with useful bits of information: 143 | # :code:: The ISO 4217 code of the currency. 144 | # :round:: Smallest unit of the currency in normal use, to which values are rounded. Default is 0.01. 145 | # :symbol:: Symbol or string usually used to denote the currency. Encoded as UTF-8. Default is ISO 4217 code. 146 | # :suffix:: +true+ if the currency symbol appears after the number, +false+ if it appears before. 147 | # :space:: Whether or not to leave a space between the number and the currency symbol. 148 | # :digits:: Number of digits to display after the decimal point. 149 | def currency_info(code, options={}) 150 | code = code.to_s.upcase 151 | valid_options = [:symbol, :round, :suffix, :space, :digits] 152 | info = {:code => code, :symbol => code, :round => 0.01, :suffix => nil, :space => nil, :digits => nil} 153 | if ::Invoicing::CurrencyValue::CURRENCIES.has_key? code 154 | info.update(::Invoicing::CurrencyValue::CURRENCIES[code]) 155 | end 156 | options.each_pair {|key, value| info[key] = value if valid_options.include? key } 157 | 158 | info[:suffix] ||= (info[:code] == info[:symbol]) && !info[:code].nil? 159 | info[:space] ||= info[:suffix] 160 | info[:digits] = -Math.log10(info[:round]).floor if info[:digits].nil? 161 | info[:digits] = 0 if info[:digits] < 0 162 | 163 | info 164 | end 165 | 166 | # Given the three-letter ISO 4217 code of a currency and a BigDecimal value, returns the 167 | # value formatted as an UTF-8 string, ready for human consumption. 168 | # 169 | # FIXME: This method currently does not take locale into account -- it always uses the dot 170 | # as decimal separator and the comma as thousands separator. 171 | def format_value(currency_code, value, options={}) 172 | info = currency_info(currency_code, options) 173 | 174 | negative = false 175 | if value < 0 176 | negative = true 177 | value = -value 178 | end 179 | 180 | value = "%.#{info[:digits]}f" % value 181 | while value.sub!(/(\d+)(\d\d\d)/, '\1,\2'); end 182 | value.sub!(/^\-/, '') # avoid displaying minus zero 183 | 184 | formatted = if ['', nil].include? info[:symbol] 185 | value 186 | elsif info[:space] 187 | info[:suffix] ? "#{value} #{info[:symbol]}" : "#{info[:symbol]} #{value}" 188 | else 189 | info[:suffix] ? "#{value}#{info[:symbol]}" : "#{info[:symbol]}#{value}" 190 | end 191 | 192 | if negative 193 | # default is to use proper unicode minus sign 194 | formatted = (options[:negative] == :brackets) ? "(#{formatted})" : ( 195 | (options[:negative] == :hyphen) ? "-#{formatted}" : "\xE2\x88\x92#{formatted}" 196 | ) 197 | end 198 | formatted 199 | end 200 | end 201 | end 202 | 203 | 204 | class ClassInfo < Invoicing::ClassInfo::Base #:nodoc: 205 | 206 | def initialize(model_class, previous_info, args) 207 | super 208 | new_args.each{|attr| generate_attrs(attr)} 209 | end 210 | 211 | # Generates the getter and setter method for attribute +attr+. 212 | def generate_attrs(attr) 213 | model_class.class_eval do 214 | define_method(attr) do 215 | currency_info = currency_value_class_info.currency_info_for(self) 216 | return read_attribute(attr) if currency_info.nil? 217 | round_factor = BigDecimal(currency_info[:round].to_s) 218 | 219 | value = currency_value_class_info.attr_conversion_input(self, attr) 220 | value.nil? ? nil : (value / round_factor).round * round_factor 221 | end 222 | 223 | define_method("#{attr}=") do |new_value| 224 | write_attribute(attr, new_value) 225 | end 226 | 227 | define_method("#{attr}_formatted") do |*args| 228 | options = args.first || {} 229 | value_as_float = begin 230 | Kernel.Float(send("#{attr}_before_type_cast")) 231 | rescue ArgumentError, TypeError 232 | nil 233 | end 234 | 235 | if value_as_float.nil? 236 | '' 237 | else 238 | format_currency_value(value_as_float, options.merge({:method_name => attr})) 239 | end 240 | end 241 | end 242 | end 243 | 244 | # Returns the value of the currency code column of +object+, if available; otherwise the 245 | # default currency code (set by the :currency_code option), if available; +nil+ if all 246 | # else fails. 247 | def currency_of(object) 248 | if object.attributes.has_key?(method(:currency)) || object.respond_to?(method(:currency)) 249 | get(object, :currency) 250 | else 251 | all_options[:currency_code] 252 | end 253 | end 254 | 255 | # Returns a hash of information about the currency used by model +object+. 256 | def currency_info_for(object) 257 | ::Invoicing::CurrencyValue::Formatter.currency_info(currency_of(object), all_options) 258 | end 259 | 260 | # Formats a numeric value as a nice currency string in UTF-8 encoding. 261 | # +object+ is the model object carrying the value (used to determine the currency). 262 | def format_value(object, value, options={}) 263 | options = all_options.merge(options).symbolize_keys 264 | intercept = options[:value_for_formatting] 265 | if intercept && object.respond_to?(intercept) 266 | value = object.send(intercept, value, options) 267 | end 268 | ::Invoicing::CurrencyValue::Formatter.format_value(currency_of(object), value, options) 269 | end 270 | 271 | # If other modules have registered callbacks for the event of reading a rounded attribute, 272 | # they are executed here. +attr+ is the name of the attribute being read. 273 | def attr_conversion_input(object, attr) 274 | value = nil 275 | 276 | if callback = all_options[:conversion_input] 277 | value = object.send(callback, attr) 278 | end 279 | 280 | unless value 281 | raw_value = object.read_attribute(attr) 282 | value = BigDecimal.new(raw_value.to_s) unless raw_value.nil? 283 | end 284 | value 285 | end 286 | end 287 | end 288 | end 289 | -------------------------------------------------------------------------------- /lib/invoicing/find_subclasses.rb: -------------------------------------------------------------------------------- 1 | module Invoicing 2 | # = Subclass-aware filtering by class methods 3 | # 4 | # Utility module which can be mixed into ActiveRecord::Base subclasses which use 5 | # single table inheritance. It enables you to query the database for model objects based 6 | # on static class properties without having to instantiate more model objects than necessary. 7 | # Its methods should be used as class methods, so the module should be mixed in using +extend+. 8 | # 9 | # For example: 10 | # 11 | # class Product < ActiveRecord::Base 12 | # extend Invoicing::FindSubclasses 13 | # def self.needs_refrigeration; false; end 14 | # end 15 | # 16 | # class Food < Product; end 17 | # class Bread < Food; end 18 | # class Yoghurt < Food 19 | # def self.needs_refrigeration; true; end 20 | # end 21 | # class GreekYoghurt < Yoghurt; end 22 | # 23 | # class Drink < Product; end 24 | # class SoftDrink < Drink; end 25 | # class Smoothie < Drink 26 | # def self.needs_refrigeration; true; end 27 | # end 28 | # 29 | # So we know that all +Yoghurt+ and all +Smoothie+ objects need refrigeration (including subclasses 30 | # of +Yoghurt+ and +Smoothly+, unless they override +needs_refrigeration+ again), and the others 31 | # don't. This fact is defined through a class method and not stored in the database. It needn't 32 | # necessarily be constant -- you could make +needs_refrigeration+ return +true+ or +false+ 33 | # depending on the current temperature, for example. 34 | # 35 | # Now assume that in your application you need to query all objects which need refrigeration 36 | # (and maybe also satisfy some other conditions). Since the database knows nothing about 37 | # +needs_refrigeration+, what you would have to do traditionally is to instantiate all objects 38 | # and then to filter them yourself, i.e. 39 | # 40 | # Product.find(:all).select{|p| p.class.needs_refrigeration} 41 | # 42 | # However, if only a small proportion of your products needs refrigeration, this requires you to 43 | # load many more objects than necessary, putting unnecessary load on your application. With the 44 | # +FindSubclasses+ module you can let the database do the filtering instead: 45 | # 46 | # Product.find(:all, :conditions => {:needs_refrigeration => true}) 47 | # 48 | # You could even define a named scope to do the same thing: 49 | # 50 | # class Product 51 | # named_scope :refrigerated_products, :conditions => {:needs_refrigeration => true}) 52 | # end 53 | # 54 | # Much nicer! The condition looks precisely like a condition on a database table column, even 55 | # though it actually refers to a class method. Under the hood, this query translates into: 56 | # 57 | # Product.find(:all, :conditions => {:type => ['Yoghurt', 'GreekYoghurt', 'Smoothie']}) 58 | # 59 | # And of course you can combine it with normal conditions on database table columns. If there 60 | # is a table column and a class method with the same name, +FindSublasses+ remains polite and lets 61 | # the table column take precedence. 62 | # 63 | # == How it works 64 | # 65 | # +FindSubclasses+ relies on having a list of all subclasses of your single-table-inheritance 66 | # base class; then, if you specify a condition with a key which has no corresponding database 67 | # table column, +FindSubclasses+ will check all subclasses for the return value of a class 68 | # method with that name, and search for the names of classes which match the condition. 69 | # 70 | # Purists of object-oriented programming will most likely find this appalling, and it's important 71 | # to know the limitations. In Ruby, a class can be notified if it subclassed, by defining the 72 | # Class#inherited method; we use this to gather together a list of subclasses. Of course, 73 | # we won't necessarily know about every class in the world which may subclass our class; in 74 | # particular, Class#inherited won't be called until that subclass is loaded. 75 | # 76 | # If you're including the Ruby files with the subclass definitions using +require+, we will learn 77 | # about subclasses as soon as they are defined. However, if class loading is delayed until a 78 | # class is first used (for example, ActiveSupport::Dependencies does this with model 79 | # objects in Rails projects), we could run into a situation where we don't yet know about all 80 | # subclasses used in a project at the point where we need to process a class method condition. 81 | # This would cause us to omit some objects we should have found. 82 | # 83 | # To prevent this from happening, this module searches for all types of object currently stored 84 | # in the table (along the lines of SELECT DISTINCT type FROM table_name), and makes sure 85 | # all class names mentioned there are loaded before evaluating a class method condition. Note that 86 | # this doesn't necessarily load all subclasses, but at least it loads those which currently have 87 | # instances stored in the database, so we won't omit any objects when selecting from the table. 88 | # There is still room for race conditions to occur, but otherwise it should be fine. If you want 89 | # to be on the safe side you can ensure all subclasses are loaded when your application 90 | # initialises -- but that's not completely DRY ;-) 91 | module FindSubclasses 92 | 93 | # Overrides ActiveRecord::Base.sanitize_sql_hash_for_conditions since this is the method 94 | # used to transform a hash of conditions into an SQL query fragment. This overriding method 95 | # searches for class method conditions in the hash and transforms them into a condition on the 96 | # class name. All further work is delegated back to the superclass method. 97 | # 98 | # Condition formats are very similar to those accepted by +ActiveRecord+: 99 | # {:my_class_method => 'value'} # known_subclasses.select{|cls| cls.my_class_method == 'value' } 100 | # {:my_class_method => [1, 2]} # known_subclasses.select{|cls| [1, 2].include?(cls.my_class_method) } 101 | # {:my_class_method => 3..6} # known_subclasses.select{|cls| (3..6).include?(cls.my_class_method) } 102 | # {:my_class_method => true} # known_subclasses.select{|cls| cls.my_class_method } 103 | # {:my_class_method => false} # known_subclasses.reject{|cls| cls.my_class_method } 104 | def sanitize_sql_hash_for_conditions(attrs, table_name = quoted_table_name) 105 | new_attrs = {} 106 | 107 | attrs.each_pair do |attr, value| 108 | attr = attr_base = attr.to_s 109 | attr_table_name = table_name 110 | 111 | # Extract table name from qualified attribute names 112 | attr_table_name, attr_base = attr.split('.', 2) if attr.include?('.') 113 | 114 | if columns_hash.include?(attr_base) || ![self.table_name, quoted_table_name].include?(attr_table_name) 115 | new_attrs[attr] = value # Condition on a table column, or another table -- pass through unmodified 116 | else 117 | begin 118 | matching_classes = select_matching_subclasses(attr_base, value) 119 | new_attrs["#{self.table_name}.#{inheritance_column}"] = matching_classes.map{|cls| cls.name.to_s} 120 | rescue NoMethodError 121 | new_attrs[attr] = value # If the class method doesn't exist, fall back to passing condition through unmodified 122 | end 123 | end 124 | end 125 | 126 | super(new_attrs, table_name) 127 | end 128 | 129 | # Returns a list of those classes within +known_subclasses+ which match a condition 130 | # method_name => value. May raise +NoMethodError+ if a class object does not 131 | # respond to +method_name+. 132 | def select_matching_subclasses(method_name, value, table = table_name, type_column = inheritance_column) 133 | known_subclasses(table, type_column).select do |cls| 134 | returned = cls.send(method_name) 135 | (returned == value) or case value 136 | when true then !!returned 137 | when false then !returned 138 | when Array, Range then value.include?(returned) 139 | end 140 | end 141 | end 142 | 143 | # Ruby callback which is invoked when a subclass is created. We use this to build a list of known 144 | # subclasses. 145 | def inherited(subclass) 146 | remember_subclass subclass 147 | super 148 | end 149 | 150 | # Add +subclass+ to the list of know subclasses of this class. 151 | def remember_subclass(subclass) 152 | @known_subclasses ||= [self] 153 | @known_subclasses << subclass unless @known_subclasses.include? subclass 154 | self.superclass.remember_subclass(subclass) if self.superclass.respond_to? :remember_subclass 155 | end 156 | 157 | # Return the list of all known subclasses of this class, if necessary checking the database for 158 | # classes which have not yet been loaded. 159 | def known_subclasses(table = table_name, type_column = inheritance_column) 160 | load_all_subclasses_found_in_database(table, type_column) 161 | @known_subclasses ||= [self] 162 | end 163 | 164 | private 165 | # Query the database for all qualified class names found in the +type_column+ column 166 | # (called +type+ by default), and check that classes of that name have been loaded by the Ruby 167 | # interpreter. If a type name is encountered which cannot be loaded, 168 | # ActiveRecord::SubclassNotFound is raised. 169 | # 170 | # TODO: Cache this somehow, to avoid querying for class names more often than necessary. It's not 171 | # obvious though how to do this best -- a different Ruby instance may insert a row into the 172 | # database with a type which is not yet loaded in this interpreter. Maybe reloading the list 173 | # of types from the database every 30-60 seconds or so would be a compromise? 174 | def load_all_subclasses_found_in_database(table = table_name, type_column = inheritance_column) 175 | quoted_table_name = connection.quote_table_name(table) 176 | quoted_inheritance_column = connection.quote_column_name(type_column) 177 | query = "SELECT DISTINCT #{quoted_inheritance_column} FROM #{quoted_table_name}" 178 | for subclass_name in connection.select_all(query).map{|record| record[type_column]} 179 | unless subclass_name.blank? # empty string or nil means base class 180 | begin 181 | compute_type(subclass_name) 182 | rescue NameError 183 | raise ActiveRecord::SubclassNotFound, # Error message borrowed from ActiveRecord::Base 184 | "The single-table inheritance mechanism failed to locate the subclass: '#{subclass_name}'. " + 185 | "This error is raised because the column '#{type_column}' is reserved for storing the class in case of inheritance. " + 186 | "Please rename this column if you didn't intend it to be used for storing the inheritance class " + 187 | "or overwrite #{self.to_s}.inheritance_column to use another column for that information." 188 | end 189 | end 190 | end 191 | end 192 | end 193 | end -------------------------------------------------------------------------------- /lib/invoicing/ledger_item/render_ubl.rb: -------------------------------------------------------------------------------- 1 | require 'builder' 2 | 3 | module Invoicing 4 | module LedgerItem 5 | # Included into ActiveRecord model object when +acts_as_ledger_item+ is invoked. 6 | module RenderUBL 7 | 8 | # Renders this invoice or credit note into a complete XML document conforming to the 9 | # {OASIS Universal Business Language}[http://ubl.xml.org/] (UBL) open standard for interchange 10 | # of business documents ({specification}[http://www.oasis-open.org/committees/ubl/]). This 11 | # format, albeit a bit verbose, is increasingly being adopted as an international standard. It 12 | # can represent some very complicated multi-currency, multi-party business relationships, but 13 | # is still quite usable for simple cases. 14 | # 15 | # It is recommended that you present machine-readable UBL data in your application in the 16 | # same way as you present human-readable invoices in HTML. For example, in a Rails controller, 17 | # you could use: 18 | # 19 | # class AccountsController < ApplicationController 20 | # def show 21 | # @ledger_item = LedgerItem.find(params[:id]) 22 | # # ... check whether current user has access to this document ... 23 | # respond_to do |format| 24 | # format.html # show.html.erb 25 | # format.xml { render :xml => @ledger_item.render_ubl } 26 | # end 27 | # end 28 | # end 29 | def render_ubl(options={}) 30 | UBLOutputBuilder.new(self, options).build 31 | end 32 | 33 | 34 | class UBLOutputBuilder #:nodoc: 35 | # XML Namespaces required by UBL 36 | UBL_NAMESPACES = { 37 | "xmlns:cac" => "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2", 38 | "xmlns:cbc" => "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" 39 | } 40 | 41 | UBL_DOC_NAMESPACES = { 42 | :Invoice => "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2", 43 | :SelfBilledInvoice => "urn:oasis:names:specification:ubl:schema:xsd:SelfBilledInvoice-2", 44 | :CreditNote => "urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2", 45 | :SelfBilledCreditNote => "urn:oasis:names:specification:ubl:schema:xsd:SelfBilledCreditNote-2" 46 | } 47 | 48 | attr_reader :ledger_item, :options, :cached_values, :doc_type, :factor 49 | 50 | def initialize(ledger_item, options) 51 | @ledger_item = ledger_item 52 | @options = options 53 | @cached_values = {} 54 | subtype = ledger_item.send(:ledger_item_class_info).subtype 55 | @doc_type = 56 | if [:invoice, :credit_note].include? subtype 57 | if total_amount >= BigDecimal('0') 58 | @factor = BigDecimal('1') 59 | sender_details.symbolize_keys[:is_self] ? :Invoice : :SelfBilledInvoice 60 | else 61 | @factor = BigDecimal('-1') 62 | sender_details.symbolize_keys[:is_self] ? :CreditNote : :SelfBilledCreditNote 63 | end 64 | else 65 | raise RuntimeError, "render_ubl not implemented for ledger item subtype #{subtype.inspect}" 66 | end 67 | end 68 | 69 | # For convenience while building the XML structure, method_missing redirects method calls 70 | # to the ledger item (taking account of method renaming via acts_as_ledger_item options); 71 | # calls to foo_of(line_item) are redirected to line_item.foo (taking account of method 72 | # renaming via acts_as_line_item options). 73 | def method_missing(method_id, *args, &block) 74 | method_id = method_id.to_sym 75 | if method_id.to_s =~ /^(.*)_of$/ 76 | method_id = $1.to_sym 77 | line_item = args[0] 78 | line_item.send(:line_item_class_info).get(line_item, method_id) 79 | else 80 | cached_values[method_id] ||= ledger_item.send(:ledger_item_class_info).get(ledger_item, method_id) 81 | end 82 | end 83 | 84 | # Returns a UBL XML rendering of the ledger item previously passed to the constructor. 85 | def build 86 | ubl = Builder::XmlMarkup.new :indent => 4 87 | ubl.instruct! :xml 88 | 89 | ubl.ubl doc_type, UBL_NAMESPACES.clone.update({'xmlns:ubl' => UBL_DOC_NAMESPACES[doc_type]}) do |invoice| 90 | invoice.cbc :ID, identifier 91 | invoice.cbc :UUID, uuid if uuid 92 | 93 | issue_date_formatted, issue_time_formatted = date_and_time(issue_date || Time.now) 94 | invoice.cbc :IssueDate, issue_date_formatted 95 | invoice.cbc :IssueTime, issue_time_formatted 96 | 97 | # Different document types have the child elements InvoiceTypeCode, Note and 98 | # TaxPointDate in a different order. WTF?! 99 | if doc_type == :Invoice 100 | invoice.cbc :InvoiceTypeCode, method_missing(:type) 101 | invoice.cbc :Note, description 102 | invoice.cbc :TaxPointDate, issue_date_formatted 103 | else 104 | invoice.cbc :TaxPointDate, issue_date_formatted 105 | invoice.cbc :InvoiceTypeCode, method_missing(:type) if doc_type == :SelfBilledInvoice 106 | invoice.cbc :Note, description 107 | end 108 | 109 | invoice.cac :InvoicePeriod do |invoice_period| 110 | build_period(invoice_period, period_start, period_end) 111 | end if period_start && period_end 112 | 113 | if [:Invoice, :CreditNote].include?(doc_type) 114 | 115 | invoice.cac :AccountingSupplierParty do |supplier| 116 | build_party supplier, sender_details 117 | end 118 | invoice.cac :AccountingCustomerParty do |customer| 119 | customer.cbc :SupplierAssignedAccountID, recipient_id 120 | build_party customer, recipient_details 121 | end 122 | 123 | elsif [:SelfBilledInvoice, :SelfBilledCreditNote].include?(doc_type) 124 | 125 | invoice.cac :AccountingCustomerParty do |customer| 126 | build_party customer, recipient_details 127 | end 128 | invoice.cac :AccountingSupplierParty do |supplier| 129 | supplier.cbc :CustomerAssignedAccountID, sender_id 130 | build_party supplier, sender_details 131 | end 132 | 133 | end 134 | 135 | invoice.cac :PaymentTerms do |payment_terms| 136 | payment_terms.cac :SettlementPeriod do |settlement_period| 137 | build_period(settlement_period, issue_date || Time.now, due_date) 138 | end 139 | end if due_date && [:Invoice, :SelfBilledInvoice].include?(doc_type) 140 | 141 | invoice.cac :TaxTotal do |tax_total| 142 | tax_total.cbc :TaxAmount, (factor*tax_amount).to_s, :currencyID => currency 143 | end if tax_amount 144 | 145 | invoice.cac :LegalMonetaryTotal do |monetary_total| 146 | monetary_total.cbc :TaxExclusiveAmount, (factor*(total_amount - tax_amount)).to_s, 147 | :currencyID => currency if tax_amount 148 | monetary_total.cbc :PayableAmount, (factor*total_amount).to_s, :currencyID => currency 149 | end 150 | 151 | line_items.sorted(:tax_point).each do |line_item| 152 | line_tag = if [:CreditNote, :SelfBilledCreditNote].include? doc_type 153 | :CreditNoteLine 154 | else 155 | :InvoiceLine 156 | end 157 | 158 | invoice.cac line_tag do |invoice_line| 159 | build_line_item(invoice_line, line_item) 160 | end 161 | end 162 | end 163 | ubl.target! 164 | end 165 | 166 | 167 | # Given a Builder::XmlMarkup instance and two datetime objects, builds a UBL 168 | # representation of the period between the two dates and times, something like the 169 | # following: 170 | # 171 | # 2008-05-06 172 | # 12:34:56+02:00 173 | # 2008-07-02 174 | # 01:02:03+02:00 175 | def build_period(xml, start_datetime, end_datetime) 176 | start_date, start_time = date_and_time(start_datetime) 177 | end_date, end_time = date_and_time(end_datetime) 178 | xml.cbc :StartDate, start_date 179 | xml.cbc :StartTime, start_time 180 | xml.cbc :EndDate, end_date 181 | xml.cbc :EndTime, end_time 182 | end 183 | 184 | 185 | # Given a Builder::XmlMarkup instance and a supplier/customer details hash (as 186 | # returned by LedgerItem#sender_details and LedgerItem#recipient_details, 187 | # builds a UBL representation of that party, something like the following: 188 | # 189 | # 190 | # 191 | # The Big Bank 192 | # 193 | # 194 | # Paved With Gold Street 195 | # London 196 | # E14 5HQ 197 | # GB 198 | # 199 | # 200 | def build_party(xml, details) 201 | details = details.symbolize_keys 202 | xml.cac :Party do |party| 203 | party.cac :PartyName do |party_name| 204 | party_name.cbc :Name, details[:name] 205 | end if details[:name] 206 | 207 | party.cac :PostalAddress do |postal_address| 208 | street1, street2 = details[:address].strip.split("\n", 2) 209 | postal_address.cbc :StreetName, street1 if street1 210 | postal_address.cbc :AdditionalStreetName, street2 if street2 211 | postal_address.cbc :CityName, details[:city] if details[:city] 212 | postal_address.cbc :PostalZone, details[:postal_code] if details[:postal_code] 213 | postal_address.cbc :CountrySubentity, details[:state] if details[:state] 214 | postal_address.cac :Country do |country| 215 | country.cbc :IdentificationCode, details[:country_code] if details[:country_code] 216 | country.cbc :Name, details[:country] if details[:country] 217 | end if details[:country_code] || details[:country] 218 | end 219 | 220 | party.cac :PartyTaxScheme do |party_tax_scheme| 221 | party_tax_scheme.cbc :CompanyID, details[:tax_number] 222 | party_tax_scheme.cac :TaxScheme do |tax_scheme| 223 | tax_scheme.cbc :ID, "VAT" # TODO: make country-dependent (e.g. GST in Australia) 224 | end 225 | end if details[:tax_number] 226 | 227 | party.cac :Contact do |contact| 228 | contact.cbc :Name, details[:contact_name] 229 | end if details[:contact_name] 230 | end 231 | end 232 | 233 | 234 | # Given a Builder::XmlMarkup instance and a +LineItem+ instance, builds a UBL 235 | # representation of that line item, something like the following: 236 | # 237 | # 123 238 | # 0cc659f0-cfac-012b-481d-0017f22d32c0 239 | # 1 240 | # 123.45 241 | # 2009-01-01 242 | # 12.34 243 | # Foo bar baz 244 | def build_line_item(invoice_line, line_item) 245 | invoice_line.cbc :ID, id_of(line_item) 246 | invoice_line.cbc :UUID, uuid_of(line_item) if uuid_of(line_item) 247 | quantity_tag = [:Invoice, :SelfBilledInvoice].include?(doc_type) ? :InvoicedQuantity : :CreditedQuantity 248 | invoice_line.cbc quantity_tag, quantity_of(line_item) if quantity_of(line_item) 249 | invoice_line.cbc :LineExtensionAmount, (factor*net_amount_of(line_item)).to_s, :currencyID => currency 250 | if tax_point_of(line_item) 251 | tax_point_date, tax_point_time = date_and_time(tax_point_of(line_item)) 252 | invoice_line.cbc :TaxPointDate, tax_point_date 253 | end 254 | 255 | invoice_line.cac :TaxTotal do |tax_total| 256 | tax_total.cbc :TaxAmount, (factor*tax_amount_of(line_item)).to_s, :currencyID => currency 257 | end if tax_amount_of(line_item) 258 | 259 | invoice_line.cac :Item do |item| 260 | item.cbc :Description, description_of(line_item) 261 | #cac:BuyersItemIdentification 262 | #cac:SellersItemIdentification 263 | #cac:ClassifiedTaxCategory 264 | #cac:ItemInstance 265 | end 266 | 267 | #cac:Price 268 | end 269 | 270 | private 271 | 272 | # Returns an array of two strings, [date, time] in the format specified by UBL, 273 | # for a given datetime value. 274 | def date_and_time(value) 275 | value.in_time_zone(Time.zone || 'Etc/UTC').xmlschema.split('T') 276 | end 277 | end 278 | end 279 | end 280 | end 281 | -------------------------------------------------------------------------------- /lib/invoicing/line_item.rb: -------------------------------------------------------------------------------- 1 | module Invoicing 2 | # = Line item objects 3 | # 4 | # A line item is a single charge on an invoice or credit note, for example representing the sale 5 | # of one particular product. An invoice or credit note with a non-zero +total_amount+ must have at 6 | # least one +LineItem+ object associated with it, and its +total_amount+ must equal the sum of the 7 | # +net_amount+ and +tax_amount+ values of all +LineItem+ objects associated with it. For details 8 | # on invoices and credit notes, see the +LedgerItem+ module. 9 | # 10 | # Many of the important principles set down in the +LedgerItem+ module also apply for line items; 11 | # for example, once you have created a line item you generally shouldn't change it again. If you 12 | # need to correct a mistake, create an additional line item of the same type but a negative value. 13 | # 14 | # == Using +LineItem+ 15 | # 16 | # In all likelihood you will have different types of charges which you need to make to your customers. 17 | # We store all those different types of line item in the same database table and use ActiveRecord's 18 | # single table inheritance to build a class hierarchy. You must create at least one line item 19 | # model class in your application, like this: 20 | # 21 | # class LineItem < ActiveRecord::Base 22 | # acts_as_line_item 23 | # belongs_to :ledger_item 24 | # end 25 | # 26 | # You may then create a class hierarchy to suit your needs, for example: 27 | # 28 | # class ProductSale < LineItem 29 | # belongs_to :product 30 | # 31 | # def description 32 | # product.title 33 | # end 34 | # end 35 | # 36 | # class ShippingCharges < LineItem 37 | # def description 38 | # "Shipping charges" 39 | # end 40 | # end 41 | # 42 | # You may associate line items of any type with credit notes and invoices interchangeably. This 43 | # means, for example, that if you overcharge a customer for shipping, you can send them a credit 44 | # note with a +ShippingCharges+ line item, thus making it explicit what it is you are refunding. 45 | # On a credit note/refund the line item's +net_amount+ and +tax_amount+ should be negative. 46 | # +Payment+ records usually do not have any associated line items. 47 | # 48 | # == Required methods/database columns 49 | # 50 | # The following methods/database columns are required for +LineItem+ objects (you may give them 51 | # different names, but then you need to tell +acts_as_line_item+ about your custom names): 52 | # 53 | # +type+:: 54 | # String to store the class name, for ActiveRecord single table inheritance. 55 | # 56 | # +ledger_item+:: 57 | # You should define an association belongs_to :ledger_item, ... which returns the 58 | # +LedgerItem+ object (invoice/credit note) to which this line item belongs. 59 | # 60 | # +ledger_item_id+:: 61 | # A foreign key of integer type, which stores the ID of the model object returned by the 62 | # +ledger_item+ association. 63 | # 64 | # +net_amount+:: 65 | # A decimal column containing the monetary amount charged by this line item, not including tax. 66 | # The value is typically positive on an invoice and negative on a credit note. The currency is 67 | # not explicitly specified on the line item, but is taken to be the currency of the invoice or 68 | # credit note to which it belongs. (This is deliberate, because you mustn't mix different 69 | # currencies within one invoice.) See the documentation of the +CurrencyValue+ module for notes 70 | # on suitable datatypes for monetary values. +acts_as_currency_value+ is automatically applied 71 | # to this attribute. 72 | # 73 | # +tax_amount+:: 74 | # A decimal column containing the monetary amount of tax which is added to +net_amount+ to 75 | # obtain the total price. This may of course be zero if no tax applies; otherwise it should have 76 | # the same sign as +net_amount+. +CurrencyValue+ applies as with +net_amount+. If you have 77 | # several different taxes being applied, please check with your accountant. We suggest that you 78 | # put VAT or sales tax in this +tax_amount+ column, and any other taxes (e.g. duty on alcohol or 79 | # tobacco, or separate state/city taxes) in separate line items. If you are not obliged to pay 80 | # tax, lucky you -- put zeroes in this column and await the day when you have enough business 81 | # that you *do* have to pay tax. 82 | # 83 | # +description+:: 84 | # A method which returns a short string explaining to your user what this line item is for. 85 | # Can be a database column but doesn't have to be. 86 | # 87 | # == Optional methods/database columns 88 | # 89 | # The following methods/database columns are optional, but recommended for +LineItem+ objects: 90 | # 91 | # +uuid+:: 92 | # A Universally Unique Identifier (UUID)[http://en.wikipedia.org/wiki/UUID] string for this line item. 93 | # It may seem unnecessary now, but may help you to keep track of your data later on as your system 94 | # grows. If you have the +uuid+ gem installed and this column is present, a UUID is automatically 95 | # generated when you create a new line item. 96 | # 97 | # +tax_point+:: 98 | # A datetime column which indicates the date on which the sale is made and/or the service is provided. 99 | # It is related to the +issue_date+ on the associated invoice/credit note, but does not necessarily 100 | # have the same value. The exact technicalities will vary by jurisdiction, but generally this is the 101 | # point in time which determines into which month or which tax period you count a sale. The value may 102 | # be the same as +created_at+ or +updated_at+, but not necessarily. 103 | # 104 | # +tax_rate_id+, +tax_rate+:: 105 | # +tax_rate_id+ is a foreign key of integer type, and +tax_rate+ is a +belongs_to+ association 106 | # based on it. It refers to another model in your application which represents the tax rate 107 | # applied to this line item. The tax rate model object should use +acts_as_tax_rate+. This 108 | # attribute is necessary if you want tax calculations to be performed automatically. 109 | # 110 | # +price_id+, +price+:: 111 | # +price_id+ is a foreign key of integer type, and +price+ is a +belongs_to+ association based 112 | # on it. It refers to another model in your application which represents the unit price (e.g. a 113 | # reference to a the product, or to a particular price band of a service). The model object thus 114 | # referred to should use +acts_as_price+. This attribute allows you to get better reports of how 115 | # much you sold of what. 116 | # 117 | # +quantity+:: 118 | # A numeric (integer or decimal) type, saying how many units of a particular product or service 119 | # this line item represents. Default is 1. Note that if you specify a +quantity+, the values for 120 | # +net_amount+ and +tax_amount+ must be the cost of the given quantity as a whole; if you need 121 | # to display the unit price, you can get it by dividing +net_amount+ by +quantity+, or by 122 | # referring to the +price+ association. 123 | # 124 | # +creator_id+:: 125 | # The ID of the user whose action caused this line item to be created or updated. This can be useful 126 | # for audit trail purposes, particularly if you allow multiple users of your application to act on 127 | # behalf of the same customer organisation. 128 | # 129 | # +created_at+, +updated_at+:: 130 | # These standard datetime columns are also recommended. 131 | # 132 | module LineItem 133 | module ActMethods 134 | # Declares that the current class is a model for line items (i.e. individual items on invoices 135 | # and credit notes). 136 | # 137 | # The name of any attribute or method required by +LineItem+ (as documented on the 138 | # +LineItem+ module) may be used as an option, with the value being the name under which 139 | # that particular method or attribute can be found. This allows you to use names other than 140 | # the defaults. For example, if your database column storing the line item value is called 141 | # +net_price+ instead of +net_amount+: 142 | # 143 | # acts_as_line_item :net_amount => :net_price 144 | def acts_as_line_item(*args) 145 | Invoicing::ClassInfo.acts_as(Invoicing::LineItem, self, args) 146 | 147 | info = line_item_class_info 148 | if info.previous_info.nil? # Called for the first time? 149 | # Set the 'amount' columns to act as currency values 150 | acts_as_currency_value(info.method(:net_amount), info.method(:tax_amount)) 151 | 152 | before_validation :calculate_tax_amount 153 | 154 | extend Invoicing::FindSubclasses 155 | 156 | # Dynamically created named scopes 157 | named_scope :in_effect, lambda{ 158 | ledger_assoc_id = line_item_class_info.method(:ledger_item).to_sym 159 | ledger_refl = reflections[ledger_assoc_id] 160 | ledger_table = ledger_refl.table_name # not quoted_table_name because it'll be quoted again 161 | status_column = ledger_refl.klass.send(:ledger_item_class_info).method(:status) 162 | { :joins => ledger_assoc_id, 163 | :conditions => {"#{ledger_table}.#{status_column}" => ['closed', 'cleared'] } } 164 | } 165 | 166 | named_scope :sorted, lambda{|column| 167 | column = line_item_class_info.method(column).to_s 168 | if column_names.include?(column) 169 | {:order => "#{connection.quote_column_name(column)}, #{connection.quote_column_name(primary_key)}"} 170 | else 171 | {:order => connection.quote_column_name(primary_key)} 172 | end 173 | } 174 | end 175 | end 176 | end 177 | 178 | # Overrides the default constructor of ActiveRecord::Base when +acts_as_line_item+ 179 | # is called. If the +uuid+ gem is installed, this constructor creates a new UUID and assigns 180 | # it to the +uuid+ property when a new line item model object is created. 181 | def initialize(*args) 182 | super 183 | # Initialise uuid attribute if possible 184 | info = line_item_class_info 185 | if self.has_attribute?(info.method(:uuid)) && info.uuid_generator 186 | write_attribute(info.method(:uuid), info.uuid_generator.generate) 187 | end 188 | end 189 | 190 | # Returns the currency code of the ledger item to which this line item belongs. 191 | def currency 192 | ledger_item = line_item_class_info.get(self, :ledger_item) 193 | raise RuntimeError, 'Cannot determine currency for line item without a ledger item' if ledger_item.nil? 194 | ledger_item.send(:ledger_item_class_info).get(ledger_item, :currency) 195 | end 196 | 197 | def calculate_tax_amount 198 | return unless respond_to? :net_amount_taxed 199 | self.tax_amount = net_amount_taxed - net_amount 200 | end 201 | 202 | # The sum of +net_amount+ and +tax_amount+. 203 | def gross_amount 204 | net_amount = line_item_class_info.get(self, :net_amount) 205 | tax_amount = line_item_class_info.get(self, :tax_amount) 206 | (net_amount && tax_amount) ? (net_amount + tax_amount) : nil 207 | end 208 | 209 | # +gross_amount+ formatted in human-readable form using the line item's currency. 210 | def gross_amount_formatted 211 | format_currency_value(gross_amount) 212 | end 213 | 214 | # We don't actually implement anything using +method_missing+ at the moment, but use it to 215 | # generate slightly more useful error messages in certain cases. 216 | def method_missing(method_id, *args) 217 | method_name = method_id.to_s 218 | if ['ledger_item', line_item_class_info.method(:ledger_item)].include? method_name 219 | raise RuntimeError, "You need to define an association like 'belongs_to :ledger_item' on #{self.class.name}. If you " + 220 | "have defined the association with a different name, pass the option :ledger_item => :your_association_name to " + 221 | "acts_as_line_item." 222 | elsif method_name =~ /^amount/ 223 | send("net_#{method_name}", *args) 224 | else 225 | super 226 | end 227 | end 228 | 229 | 230 | # Stores state in the ActiveRecord class object 231 | class ClassInfo < Invoicing::ClassInfo::Base #:nodoc: 232 | attr_reader :uuid_generator 233 | 234 | def initialize(model_class, previous_info, args) 235 | super 236 | 237 | begin # try to load the UUID gem 238 | require 'uuid' 239 | @uuid_generator = UUID.new 240 | rescue LoadError, NameError # silently ignore if gem not found 241 | @uuid_generator = nil 242 | end 243 | end 244 | 245 | # Allow methods generated by +CurrencyValue+ to be renamed as well 246 | def method(name) 247 | if name.to_s =~ /^(.*)_formatted$/ 248 | "#{super($1)}_formatted" 249 | else 250 | super 251 | end 252 | end 253 | end 254 | end 255 | end -------------------------------------------------------------------------------- /lib/invoicing/price.rb: -------------------------------------------------------------------------------- 1 | module Invoicing 2 | module Price 3 | module ActMethods 4 | def acts_as_price(*args) 5 | 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/invoicing/tax_rate.rb: -------------------------------------------------------------------------------- 1 | module Invoicing 2 | module TaxRate 3 | module ActMethods 4 | def acts_as_tax_rate(*args) 5 | Invoicing::ClassInfo.acts_as(Invoicing::TaxRate, self, args) 6 | info = tax_rate_class_info 7 | 8 | if info.previous_info.nil? # Called for the first time? 9 | # Import TimeDependent functionality 10 | acts_as_time_dependent :value => :rate 11 | end 12 | end 13 | end 14 | 15 | # Stores state in the ActiveRecord class object 16 | class ClassInfo < Invoicing::ClassInfo::Base #:nodoc: 17 | def initialize(model_class, previous_info, args) 18 | super 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/invoicing/taxable.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | module Invoicing 4 | # = Computation of tax on prices 5 | # 6 | # This module provides a general-purpose framework for calculating tax. Its most common application 7 | # will probably be for computing VAT/sales tax on the price of your product, but since you can easily 8 | # attach your own tax computation logic, it can apply in a broad variety of different situations. 9 | # 10 | # Computing the tax on a price may be as simple as multiplying it with a constant factor, but in most 11 | # cases it will be more complicated. The tax rate may change over time (see +TimeDependent+), may vary 12 | # depending on the customer currently viewing the page (and the country in which they are located), 13 | # and may depend on properties of the object to which the price belongs. This module does not implement 14 | # any specific computation, but makes easy to implement specific tax regimes with minimal code duplication. 15 | # 16 | # == Using taxable attributes in a model 17 | # 18 | # If you have a model object (a subclass of ActiveRecord::Base) with a monetary quantity 19 | # (such as a price) in one or more of its database columns, you can declare that those columns/attributes 20 | # are taxable, for example: 21 | # 22 | # class MyProduct < ActiveRecord::Base 23 | # acts_as_taxable :normal_price, :promotion_price, :tax_logic => Invoicing::Countries::UK::VAT.new 24 | # end 25 | # 26 | # In the taxable columns (+normal_price+ and +promotion_price+ in this example) you must always 27 | # store values excluding tax. The option :tax_logic is mandatory, and you must give it 28 | # an instance of a 'tax logic' object; you may use one of the tax logic implementations provided with 29 | # this framework, or write your own. See below for details of what a tax logic object needs to do. 30 | # 31 | # Your database table should also contain a column +currency+, in which you store the ISO 4217 32 | # three-letter upper-case code identifying the currency of the monetary amounts in the same table row. 33 | # If your currency code column has a name other than +currency+, you need to specify the name of that 34 | # column to +acts_as_taxable+ using the :currency => '...' option. 35 | # 36 | # For each attribute which you declare as taxable, several new methods are generated on your model class: 37 | # 38 | # :: Returns the amount of money excluding tax, as stored in the database, 39 | # subject to the model object's currency rounding conventions. 40 | # =:: Assigns a new value (exclusive of tax) to the attribute. 41 | # _taxed:: Returns the amount of money including tax, as computed by the tax 42 | # logic, subject to the model object's currency rounding conventions. 43 | # _taxed=:: Assigns a new value (including tax) to the attribute. 44 | # _tax_rounding_error:: Returns a number indicating how much the tax-inclusive value of the 45 | # attribute has changed as a result of currency rounding. See the section 46 | # 'currency rounding errors' below. +nil+ if the +_taxed=+ attribute 47 | # has not been assigned. 48 | # _tax_info:: Returns a short string to inform a user about the tax status of 49 | # the value returned by _taxed; this could be 50 | # "inc. VAT", for example, if the +_taxed+ attribute includes VAT. 51 | # _tax_details:: Like +_tax_info+, but a longer string for places in the user 52 | # interface where more space is available. For example, "including 53 | # VAT at 15%". 54 | # _with_tax_info:: Convenience method for views: returns the attribute value including 55 | # tax, formatted as a human-friendly currency string in UTF-8, with 56 | # the return value of +_tax_info+ appended. For example, 57 | # "AU$1,234.00 inc. GST". 58 | # _with_tax_details:: Like +_with_tax_info+, but using +_tax_details+. For example, 59 | # "AU$1,234.00 including 10% Goods and Services Tax". 60 | # _taxed_before_type_cast:: Returns any value which you assign to _taxed= without 61 | # converting it first. This means you to can use +_taxed+ attributes as 62 | # fields in Rails forms and get the expected behaviour of form validation. 63 | # 64 | # +acts_as_currency+ is automatically called for all attributes given to +acts_as_taxable+, as well as all 65 | # generated _taxed attributes. This means you get automatic currency-specific rounding 66 | # behaviour as documented in the +CurrencyValue+ module, and you get two additional methods for free: 67 | # _formatted and _taxed_formatted, which return the untaxed and taxed amounts 68 | # respectively, formatted as a nice human-friendly string. 69 | # 70 | # The +Taxable+ module automatically converts between taxed and untaxed attributes. This works as you would 71 | # expect: you can assign to a taxed attribute and immediately read from an untaxed attribute, or vice versa. 72 | # When you store the object, only the untaxed value is written to the database. That way, if the tax rate 73 | # changes or you open your business to overseas customers, nothing changes in your database. 74 | # 75 | # == Using taxable attributes in views and forms 76 | # 77 | # The tax logic object allows you to have one single place in your application where you declare which products 78 | # are seen by which customers at which tax rate. For example, if you are a VAT registered business in an EU 79 | # country, you always charge VAT at your home country's rate to customers within your home country; however, 80 | # to a customer in a different EU country you do not charge any VAT if you have received a valid VAT registration 81 | # number from them. You see that this logic can easily become quite complicated. This complexity should be 82 | # encapsulated entirely within the tax logic object, and not require any changes to your views or controllers if 83 | # at all possible. 84 | # 85 | # The way to achieve this is to always use the +_taxed+ attributes in views and forms, unless you have a 86 | # very good reason not to. The value returned by _taxed, and the value you assign to 87 | # _taxed=, do not necessarily have to include tax; for a given customer and product, the tax may 88 | # be zero-rated or not applicable, in which case their numeric value will be the same as the untaxed attributes. 89 | # The attributes are called +_taxed+ because they may be taxed, not because they necessarily always are. It is 90 | # up to the tax logic to decide whether to return the same number, or one modified to include tax. 91 | # 92 | # The purpose of the +_tax_info+ and +_tax_details+ methods is to clarify the tax status of a given number to the 93 | # user; if the number returned by the +_taxed+ attribute does not contain tax for whatever reason, +_tax_info+ for 94 | # the same attribute should say so. 95 | # 96 | # Using these attributes, views can be kept very simple: 97 | # 98 | #

Products

99 | # 100 | # 101 | # 102 | # 103 | # 104 | # <% for product in @products %> 105 | # 106 | # 107 | # # e.g. "$25.80 (inc. tax)" 108 | # 109 | # <% end %> 110 | #
NamePrice
<%=h product.name %><%=h product.price_with_tax_info %>
111 | # 112 | #

New product

113 | # <% form_for(@product) do |f| %> 114 | # <%= f.error_messages %> 115 | #

116 | # <%= f.label :name, "Product name:" %>
117 | # <%= f.text_field :name %> 118 | #

119 | #

120 | # <%= f.label :price_taxed, "Price #{h(@product.price_tax_info)}:" %>
# e.g. "Price (inc. tax):" 121 | # <%= f.text_field :price_taxed %> 122 | #

123 | # <% end %> 124 | # 125 | # If this page is viewed by a user who shouldn't be shown tax, the numbers in the output will be different, 126 | # and it might say "excl. tax" instead of "inc. tax"; but none of that clutters the view. Moreover, any price 127 | # typed into the form will of course be converted as appropriate for that user. This is important, for 128 | # example, in an auction scenario, where you may have taxed and untaxed bidders bidding in the same 129 | # auction; their input and output is personalised depending on their account information, but for 130 | # purposes of determining the winning bidder, all bidders are automatically normalised to the untaxed 131 | # value of their bids. 132 | # 133 | # == Tax logic objects 134 | # 135 | # A tax logic object is an instance of a class with the following structure: 136 | # 137 | # class MyTaxLogic 138 | # def apply_tax(params) 139 | # # Called to convert a value without tax into a value with tax, as applicable. params is a hash: 140 | # # :model_object => The model object whose attribute is being converted 141 | # # :attribute => The name of the attribute (without '_taxed' suffix) being converted 142 | # # :value => The untaxed value of the attribute as a BigDecimal 143 | # # Should return a number greater than or equal to the input value. Don't worry about rounding -- 144 | # # CurrencyValue deals with that. 145 | # end 146 | # 147 | # def remove_tax(params) 148 | # # Does the reverse of apply_tax -- converts a value with tax into a value without tax. The params 149 | # # hash has the same format. First applying tax and then removing it again should always result in the 150 | # # starting value (for the the same object and the same environment -- it may depend on time, 151 | # # global variables, etc). 152 | # end 153 | # 154 | # def tax_info(params, *args) 155 | # # Should return a short string to explain to users which operation has been performed by apply_tax 156 | # # (e.g. if apply_tax has added VAT, the string could be "inc. VAT"). The params hash is the same as 157 | # # given to apply_tax. Additional parameters are optional; if any arguments are passed to a call to 158 | # # model_object._tax_info then they are passed on here. 159 | # end 160 | # 161 | # def tax_details(params, *args) 162 | # # Like tax_info, but should return a longer string for use in user interface elements which are less 163 | # # limited in size. 164 | # end 165 | # 166 | # def mixin_methods 167 | # # Optionally you can define a method mixin_methods which returns a list of method names which should 168 | # # be included in classes which use this tax logic. Methods defined here become instance methods of 169 | # # model objects with acts_as_taxable attributes. For example: 170 | # [:some_other_method] 171 | # end 172 | # 173 | # def some_other_method(params, *args) 174 | # # some_other_method was named by mixin_methods to be included in model objects. For example, if the 175 | # # class MyProduct uses MyTaxLogic, then MyProduct.find(1).some_other_method(:foo, 'bar') will 176 | # # translate into MyTaxLogic#some_other_method({:model_object => MyProduct.find(1)}, :foo, 'bar'). 177 | # # The model object on which the method is called is passed under the key :model_object in the 178 | # # params hash, and all other arguments to the method are simply passed on. 179 | # end 180 | # end 181 | # 182 | # 183 | # == Currency rounding errors 184 | # 185 | # Both the taxed and the untaxed value of an attribute are currency values, and so they must both be rounded 186 | # to the accuracy which is conventional for the currency in use (see the discussion of precision and rounding 187 | # in the +CurrencyValue+ module). If we are always storing untaxed values and outputting taxed values to the 188 | # user, this is not a problem. However, if we allow users to input taxed values (like in the form example 189 | # above), something curious may happen: The input value has its tax removed, is rounded to the currency's 190 | # conventional precision and stored in the database in untaxed form; then later it is loaded, tax is added 191 | # again, it is again rounded to the currency's conventional precision, and displayed to the user. If the 192 | # rounding steps have rounded the number upwards twice, or downwards twice, it may happen that the value 193 | # displayed to the user differs slightly from the one they originally entered. 194 | # 195 | # We believe that storing untaxed values and performing currency rounding are the right things to do, and this 196 | # apparent rounding error is a natural consequence. This module therefore tries to deal with the error 197 | # elegantly: If you assign a value to a taxed attribute and immediately read it again, it will return the 198 | # same value as if it had been stored and loaded again (i.e. the number you read has been rounded twice -- 199 | # make sure the currency code has been assigned to the object beforehand, so that the +CurrencyValue+ module 200 | # knows which precision to apply). 201 | # 202 | # Moreover, after assigning a value to a _taxed= attribute, the _tax_rounding_error 203 | # method can tell you whether and by how much the value has changed as a result of removing and re-applying 204 | # tax. A negative number indicates that the converted amount is less than the input; a positive number indicates 205 | # that it is more than entered by the user; and zero means that there was no difference. 206 | # 207 | module Taxable 208 | module ActMethods 209 | # Declares that one or more attributes on this model object store monetary values to which tax may be 210 | # applied. Takes one or more attribute names, followed by an options hash: 211 | # :tax_logic:: Object with instance methods apply_tax, remove_tax, tax_info and tax_details 212 | # as documented in the +Taxable+ module. Required. 213 | # :currency:: The name of the attribute/database column which stores the ISO 4217 currency 214 | # code for the monetary amounts in this model object. Required if the column 215 | # is not called +currency+. 216 | # +acts_as_taxable+ implies +acts_as_currency_value+ with the same options. See the +Taxable+ for details. 217 | def acts_as_taxable(*args) 218 | Invoicing::ClassInfo.acts_as(Invoicing::Taxable, self, args) 219 | 220 | attrs = taxable_class_info.new_args.map{|a| a.to_s } 221 | currency_attrs = attrs + attrs.map{|attr| "#{attr}_taxed"} 222 | currency_opts = taxable_class_info.all_options.update({:conversion_input => :convert_taxable_value}) 223 | acts_as_currency_value(currency_attrs, currency_opts) 224 | 225 | attrs.each {|attr| generate_attr_taxable_methods(attr) } 226 | 227 | if tax_logic = taxable_class_info.all_options[:tax_logic] 228 | other_methods = (tax_logic.respond_to?(:mixin_methods) ? tax_logic.mixin_methods : []) || [] 229 | other_methods.each {|method_name| generate_attr_taxable_other_method(method_name.to_s) } 230 | else 231 | raise ArgumentError, 'You must specify a :tax_logic option for acts_as_taxable' 232 | end 233 | end 234 | end 235 | 236 | # If +write_attribute+ is called on a taxable attribute, we note whether the taxed or the untaxed 237 | # version contains the latest correct value. We don't do the conversion immediately in case the tax 238 | # logic requires the value of another attribute (which may be assigned later) to do its calculation. 239 | def write_attribute(attribute, value) #:nodoc: 240 | attribute = attribute.to_s 241 | attr_regex = taxable_class_info.all_args.map{|a| a.to_s }.join('|') 242 | @taxed_or_untaxed ||= {} 243 | @taxed_attributes ||= {} 244 | 245 | if attribute =~ /^(#{attr_regex})$/ 246 | @taxed_or_untaxed[attribute] = :untaxed 247 | @taxed_attributes[attribute] = nil 248 | elsif attribute =~ /^(#{attr_regex})_taxed$/ 249 | @taxed_or_untaxed[$1] = :taxed 250 | @taxed_attributes[$1] = value 251 | end 252 | 253 | super 254 | end 255 | 256 | # Called internally to convert between taxed and untaxed values. You shouldn't usually need to 257 | # call this method from elsewhere. 258 | def convert_taxable_value(attr) #:nodoc: 259 | attr = attr.to_s 260 | attr_without_suffix = attr.sub(/(_taxed)$/, '') 261 | to_status = ($1 == '_taxed') ? :taxed : :untaxed 262 | 263 | @taxed_or_untaxed ||= {} 264 | from_status = @taxed_or_untaxed[attr_without_suffix] || :untaxed # taxed or untaxed most recently assigned? 265 | 266 | attr_to_read = attr_without_suffix 267 | attr_to_read += '_taxed' if from_status == :taxed 268 | 269 | if from_status == :taxed && to_status == :taxed 270 | # Special case: remove tax, apply rounding errors, apply tax again, apply rounding errors again. 271 | write_attribute(attr_without_suffix, send(attr_without_suffix)) 272 | send(attr) 273 | else 274 | taxable_class_info.convert(self, attr_without_suffix, read_attribute(attr_to_read), from_status, to_status) 275 | end 276 | end 277 | 278 | protected :write_attribute, :convert_taxable_value 279 | 280 | 281 | module ClassMethods #:nodoc: 282 | # Generate additional accessor method for attribute with getter +method_name+. 283 | def generate_attr_taxable_methods(method_name) #:nodoc: 284 | 285 | define_method("#{method_name}_tax_rounding_error") do 286 | original_value = read_attribute("#{method_name}_taxed") 287 | return nil if original_value.nil? # Can only have a rounding error if the taxed attr was assigned 288 | 289 | original_value = BigDecimal.new(original_value.to_s) 290 | converted_value = send("#{method_name}_taxed") 291 | 292 | return nil if converted_value.nil? 293 | converted_value - original_value 294 | end 295 | 296 | define_method("#{method_name}_tax_info") do |*args| 297 | tax_logic = taxable_class_info.all_options[:tax_logic] 298 | tax_logic.tax_info({:model_object => self, :attribute => method_name, :value => send(method_name)}, *args) 299 | end 300 | 301 | define_method("#{method_name}_tax_details") do |*args| 302 | tax_logic = taxable_class_info.all_options[:tax_logic] 303 | tax_logic.tax_details({:model_object => self, :attribute => method_name, :value => send(method_name)}, *args) 304 | end 305 | 306 | define_method("#{method_name}_with_tax_info") do |*args| 307 | amount = send("#{method_name}_taxed_formatted") 308 | tax_info = send("#{method_name}_tax_info").to_s 309 | tax_info.blank? ? amount : "#{amount} #{tax_info}" 310 | end 311 | 312 | define_method("#{method_name}_with_tax_details") do |*args| 313 | amount = send("#{method_name}_taxed_formatted") 314 | tax_details = send("#{method_name}_tax_details").to_s 315 | tax_details.blank? ? amount : "#{amount} #{tax_details}" 316 | end 317 | 318 | define_method("#{method_name}_taxed_before_type_cast") do 319 | @taxed_attributes ||= {} 320 | @taxed_attributes[method_name] || 321 | read_attribute_before_type_cast("#{method_name}_taxed") || 322 | send("#{method_name}_taxed") 323 | end 324 | end 325 | 326 | # Generate a proxy method called +method_name+ which is forwarded to the +tax_logic+ object. 327 | def generate_attr_taxable_other_method(method_name) #:nodoc: 328 | define_method(method_name) do |*args| 329 | tax_logic = taxable_class_info.all_options[:tax_logic] 330 | tax_logic.send(method_name, {:model_object => self}, *args) 331 | end 332 | end 333 | 334 | private :generate_attr_taxable_methods, :generate_attr_taxable_other_method 335 | end # module ClassMethods 336 | 337 | 338 | class ClassInfo < Invoicing::ClassInfo::Base #:nodoc: 339 | # Performs the conversion between taxed and untaxed values. Arguments +from_status+ and 340 | # +to_status+ must each be either :taxed or :untaxed. 341 | def convert(object, attr_without_suffix, value, from_status, to_status) 342 | return nil if value.nil? 343 | value = BigDecimal.new(value.to_s) 344 | return value if from_status == to_status 345 | 346 | if to_status == :taxed 347 | all_options[:tax_logic].apply_tax({:model_object => object, :attribute => attr_without_suffix, :value => value}) 348 | else 349 | all_options[:tax_logic].remove_tax({:model_object => object, :attribute => attr_without_suffix, :value => value}) 350 | end 351 | end 352 | end 353 | 354 | end 355 | end -------------------------------------------------------------------------------- /lib/invoicing/version.rb: -------------------------------------------------------------------------------- 1 | module Invoicing 2 | module Version 3 | MAJOR = 0 4 | MINOR = 2 5 | BUILD = 1 6 | 7 | def to_a 8 | [MAJOR, MINOR, BUILD] 9 | end 10 | 11 | def to_s 12 | to_a.join(".") 13 | end 14 | 15 | module_function :to_a 16 | module_function :to_s 17 | 18 | STRING = Version.to_s 19 | end 20 | VERSION = Version.to_s 21 | end 22 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | # How to make a release. 2 | # * Configure new version number in invoicing/lib/invoicing/version.rb 3 | # * Add details on the new release to History.txt 4 | 5 | export VERSION=x.y.z 6 | 7 | # Test locally: 8 | (cd invoicing; rake manifest; rake install_gem) 9 | (cd invoicing_generator; rake manifest; rake install_gem) 10 | git commit -a -m "Set version number to $VERSION" 11 | 12 | # `rake release` expects VERSION=... environment variable to be set 13 | (cd invoicing; rake release) 14 | (cd invoicing_generator; rake release) 15 | (cd invoicing; rake post_news) 16 | 17 | # Tag and push it 18 | git tag -a -m "Tagging release $VERSION" "v$VERSION" 19 | git push github 20 | git push rubyforge 21 | git push --tags github 22 | git push --tags rubyforge 23 | 24 | # Build docs and upload them 25 | (cd invoicing; rake docs; rsync -av --delete --ignore-errors doc/ ept@rubyforge.org:/var/www/gforge-projects/invoicing/doc) 26 | -------------------------------------------------------------------------------- /script/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # File: script/console 3 | irb = RUBY_PLATFORM =~ /(:?mswin|mingw)/ ? 'irb.bat' : 'irb' 4 | 5 | libs = " -r irb/completion" 6 | # Perhaps use a console_lib to store any extra methods I may want available in the cosole 7 | # libs << " -r #{File.dirname(__FILE__) + '/../lib/console_lib/console_logger.rb'}" 8 | libs << " -r #{File.dirname(__FILE__) + '/../lib/invoicing.rb'}" 9 | puts "Loading invoicing gem" 10 | exec "#{irb} #{libs} --simple-prompt" -------------------------------------------------------------------------------- /script/destroy: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_ROOT = File.expand_path(File.join(File.dirname(__FILE__), '..')) 3 | 4 | begin 5 | require 'rubigen' 6 | rescue LoadError 7 | require 'rubygems' 8 | require 'rubigen' 9 | end 10 | require 'rubigen/scripts/destroy' 11 | 12 | ARGV.shift if ['--help', '-h'].include?(ARGV[0]) 13 | RubiGen::Base.use_component_sources! [:rubygems, :newgem, :newgem_theme, :test_unit] 14 | RubiGen::Scripts::Destroy.new.run(ARGV) 15 | -------------------------------------------------------------------------------- /script/generate: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_ROOT = File.expand_path(File.join(File.dirname(__FILE__), '..')) 3 | 4 | begin 5 | require 'rubigen' 6 | rescue LoadError 7 | require 'rubygems' 8 | require 'rubigen' 9 | end 10 | require 'rubigen/scripts/generate' 11 | 12 | ARGV.shift if ['--help', '-h'].include?(ARGV[0]) 13 | RubiGen::Base.use_component_sources! [:rubygems, :newgem, :newgem_theme, :test_unit] 14 | RubiGen::Scripts::Generate.new.run(ARGV) 15 | -------------------------------------------------------------------------------- /tasks/rcov.rake: -------------------------------------------------------------------------------- 1 | desc "Run tests in rcov to analyse code coverage" 2 | task :coverage do 3 | exec "rcov -x '/Library/' -T test/*_test.rb" 4 | end 5 | -------------------------------------------------------------------------------- /test/cached_record_test.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), 'test_helper.rb') 2 | 3 | class CachedRecordTest < Test::Unit::TestCase 4 | 5 | class CachedRecord < ActiveRecord::Base 6 | set_primary_key 'id2' 7 | acts_as_cached_record :id => 'id2' 8 | has_many :referrers, :class_name => 'RefersToCachedRecord', :foreign_key => 'cached_record_id' 9 | end 10 | 11 | class RefersToCachedRecord < ActiveRecord::Base 12 | belongs_to :cached_record 13 | end 14 | 15 | class CachedRecordMockDatabase < ActiveRecord::Base 16 | set_table_name 'cached_records' 17 | set_primary_key 'id2' 18 | acts_as_cached_record :id => 'id2' 19 | 20 | def self.connection 21 | @connection_mock ||= FlexMock.new('connection') 22 | end 23 | end 24 | 25 | 26 | def test_find_with_valid_id_should_return_record 27 | record = CachedRecord.find(1) 28 | assert_not_nil record 29 | assert record.kind_of?(CachedRecord) 30 | end 31 | 32 | def test_find_with_invalid_id_should_raise_exception 33 | assert_raise ActiveRecord::RecordNotFound do 34 | CachedRecord.find(99) 35 | end 36 | end 37 | 38 | def test_find_with_valid_id_should_not_access_database 39 | CachedRecordMockDatabase.connection.should_receive(:select).and_throw('should not access database') 40 | assert_not_nil CachedRecordMockDatabase.find(1) 41 | end 42 | 43 | def test_find_with_invalid_id_should_not_access_database 44 | CachedRecordMockDatabase.connection.should_receive(:select).and_throw('should not access database') 45 | assert_raise ActiveRecord::RecordNotFound do 46 | CachedRecordMockDatabase.find(99) 47 | end 48 | end 49 | 50 | def test_find_with_conditions_should_still_work 51 | assert_equal CachedRecord.find_by_value('Two'), CachedRecord.find(2) 52 | end 53 | 54 | def test_find_with_conditions_should_not_use_the_cache 55 | assert !CachedRecord.find_by_value('Two').equal?(CachedRecord.find(2)) 56 | end 57 | 58 | def test_find_without_ids_should_raise_exception 59 | assert_raise ActiveRecord::RecordNotFound do 60 | CachedRecord.find 61 | end 62 | end 63 | 64 | def test_find_with_empty_list_of_ids_should_raise_exception 65 | assert_raise ActiveRecord::RecordNotFound do 66 | CachedRecord.find(:conditions => {:id => []}) 67 | end 68 | end 69 | 70 | def test_find_with_list_of_ids_should_return_list_of_objects 71 | expected = CachedRecord.cached_record_list.sort{|r1, r2| r1.id - r2.id} 72 | assert_equal expected, CachedRecord.find([1,2]) 73 | end 74 | 75 | def test_cached_record_associations_should_still_work 76 | assert_equal 2, CachedRecord.find(1).referrers.length 77 | end 78 | 79 | def test_foreign_key_to_cached_record_should_use_cache 80 | assert RefersToCachedRecord.find(1).cached_record.equal?(CachedRecord.find(1)) 81 | end 82 | 83 | def test_cached_record_list_should_return_all_objects 84 | assert_equal 2, CachedRecord.cached_record_list.length 85 | end 86 | 87 | def test_cached_record_list_should_not_access_database 88 | CachedRecordMockDatabase.connection.should_receive(:select).and_throw('should not access database') 89 | assert_not_nil CachedRecordMockDatabase.cached_record_list 90 | end 91 | 92 | def test_reload_cache_should_do_what_it_says_on_the_tin 93 | CachedRecord.connection.execute "insert into cached_records (id2, value) values(3, 'Three')" 94 | CachedRecord.reload_cache 95 | record = CachedRecord.find(3) 96 | assert_not_nil record 97 | assert record.kind_of?(CachedRecord) 98 | assert_equal 3, CachedRecord.cached_record_list.length 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /test/class_info_test.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), 'test_helper.rb') 2 | 3 | # Mini implementation of the ClassInfo pattern, at which we can fire our tests 4 | 5 | module MyNamespace 6 | module ClassInfoTestModule 7 | module ActMethods 8 | def acts_as_class_info_test(*args) 9 | Invoicing::ClassInfo.acts_as(MyNamespace::ClassInfoTestModule, self, args) 10 | end 11 | end 12 | 13 | def get_class_info 14 | class_info_test_module_class_info 15 | end 16 | 17 | def my_instance_method 18 | class_info_test_module_class_info.the_answer / value 19 | end 20 | 21 | def my_other_instance_method 22 | class_info_test_module_class_info.not_the_answer / value 23 | end 24 | 25 | module ClassMethods 26 | def my_class_method(number) 27 | class_info_test_module_class_info.the_answer / number 28 | end 29 | 30 | def my_other_class_method(number) 31 | class_info_test_module_class_info.not_the_answer / number 32 | end 33 | 34 | def get_class_info 35 | class_info_test_module_class_info 36 | end 37 | end 38 | 39 | class ClassInfo < Invoicing::ClassInfo::Base 40 | def foo 41 | 'foo' 42 | end 43 | 44 | def the_answer 45 | all_args.first 46 | end 47 | 48 | def not_the_answer 49 | all_args.last 50 | end 51 | 52 | def option_defaults 53 | {:option1 => :baa, :option2 => :blah} 54 | end 55 | end 56 | end 57 | 58 | 59 | module ClassInfoTest2 60 | module ActMethods 61 | def acts_as_class_info_test2(*args) 62 | Invoicing::ClassInfo.acts_as(MyNamespace::ClassInfoTest2, self, args) 63 | end 64 | end 65 | 66 | module ClassMethods 67 | def test2_class_info 68 | class_info_test2_class_info 69 | end 70 | end 71 | 72 | class ClassInfo < Invoicing::ClassInfo::Base 73 | end 74 | end 75 | end 76 | 77 | ActiveRecord::Base.send(:extend, MyNamespace::ClassInfoTestModule::ActMethods) 78 | ActiveRecord::Base.send(:extend, MyNamespace::ClassInfoTest2::ActMethods) 79 | 80 | 81 | # Model objects which use the acts_as feature defined above 82 | 83 | class ClassInfoTestRecord < ActiveRecord::Base 84 | acts_as_class_info_test 42, :option1 => :moo 85 | acts_as_class_info_test 84, 42, 168, :option3 => :asdf 86 | 87 | def self.class_foo 88 | class_info_test_module_class_info.foo 89 | end 90 | 91 | def instance_foo 92 | class_info_test_module_class_info.foo 93 | end 94 | end 95 | 96 | class ClassInfoTestSubclass < ClassInfoTestRecord 97 | acts_as_class_info_test 336, :option1 => :quack, :option4 => :fdsa 98 | end 99 | 100 | class ClassInfoTestSubclass2 < ClassInfoTestRecord 101 | acts_as_class_info_test2 :option1 => :badger 102 | end 103 | 104 | class ClassInfoTestSubSubclass < ClassInfoTestSubclass2 105 | acts_as_class_info_test 112, :option3 => 1234 106 | end 107 | 108 | class ClassInfoTestEmptySubclass < ClassInfoTestRecord 109 | end 110 | 111 | class ClassInfoTest2Record < ActiveRecord::Base 112 | acts_as_class_info_test2 :option1 => :okapi, :option3 => :kangaroo 113 | 114 | def option1; 'this is option1'; end 115 | def option2; 'this is option2'; end 116 | def kangaroo; 'bounce'; end 117 | end 118 | 119 | 120 | ####################################################################################### 121 | 122 | class ClassInfoTest < Test::Unit::TestCase 123 | 124 | def test_call_into_class_info_via_class 125 | assert_equal 'foo', ClassInfoTestRecord.class_foo 126 | end 127 | 128 | def test_call_into_class_info_via_instance 129 | assert_equal 'foo', ClassInfoTestRecord.new.instance_foo 130 | end 131 | 132 | def test_mixin_superclass_instance_methods 133 | assert_equal 21, ClassInfoTestRecord.find(1).my_instance_method 134 | assert_equal 84, ClassInfoTestRecord.find(1).my_other_instance_method 135 | end 136 | 137 | def test_mixin_superclass_class_methods 138 | assert_equal 14, ClassInfoTestRecord.my_class_method(3) 139 | assert_equal 28, ClassInfoTestRecord.my_other_class_method(6) 140 | end 141 | 142 | def test_mixin_subclass_instance_methods 143 | assert_equal 14, ClassInfoTestRecord.find(2).my_instance_method 144 | assert_equal 112, ClassInfoTestRecord.find(2).my_other_instance_method 145 | end 146 | 147 | def test_mixin_subclass_class_methods 148 | assert_equal 14, ClassInfoTestSubclass.my_class_method(3) 149 | assert_equal 56, ClassInfoTestSubclass.my_other_class_method(6) 150 | end 151 | 152 | def test_all_args_in_superclass 153 | assert_equal [42, 84, 168], ClassInfoTestRecord.get_class_info.all_args 154 | end 155 | 156 | def test_all_args_in_subclass 157 | assert_equal [42, 84, 168, 336], ClassInfoTestSubclass.get_class_info.all_args 158 | end 159 | 160 | def test_all_args_in_sub_subclass 161 | assert_equal [42, 84, 168, 112], ClassInfoTestSubSubclass.get_class_info.all_args 162 | end 163 | 164 | def test_current_args_in_superclass 165 | assert_equal [84, 42, 168], ClassInfoTestRecord.get_class_info.current_args 166 | end 167 | 168 | def test_current_args_in_subclass 169 | assert_equal [336], ClassInfoTestSubclass.get_class_info.current_args 170 | end 171 | 172 | def test_current_args_in_sub_subclass 173 | assert_equal [112], ClassInfoTestSubSubclass.get_class_info.current_args 174 | end 175 | 176 | def test_new_args_in_superclass 177 | assert_equal [84, 168], ClassInfoTestRecord.get_class_info.new_args 178 | end 179 | 180 | def test_new_args_in_subclass 181 | assert_equal [336], ClassInfoTestSubclass.get_class_info.new_args 182 | end 183 | 184 | def test_new_args_in_sub_subclass 185 | assert_equal [112], ClassInfoTestSubSubclass.get_class_info.new_args 186 | end 187 | 188 | def test_all_options_in_superclass 189 | assert_equal :moo, ClassInfoTestRecord.get_class_info.all_options[:option1] 190 | assert_equal :blah, ClassInfoTestRecord.get_class_info.all_options[:option2] 191 | assert_equal :asdf, ClassInfoTestRecord.get_class_info.all_options[:option3] 192 | assert_nil ClassInfoTestRecord.get_class_info.all_options[:option4] 193 | end 194 | 195 | def test_all_options_in_subclass 196 | assert_equal :quack, ClassInfoTestSubclass.get_class_info.all_options[:option1] 197 | assert_equal :blah, ClassInfoTestSubclass.get_class_info.all_options[:option2] 198 | assert_equal :asdf, ClassInfoTestSubclass.get_class_info.all_options[:option3] 199 | assert_equal :fdsa, ClassInfoTestSubclass.get_class_info.all_options[:option4] 200 | end 201 | 202 | def test_all_options_in_sub_subclass 203 | assert_equal :moo, ClassInfoTestSubSubclass.get_class_info.all_options[:option1] 204 | assert_equal :blah, ClassInfoTestSubSubclass.get_class_info.all_options[:option2] 205 | assert_equal 1234, ClassInfoTestSubSubclass.get_class_info.all_options[:option3] 206 | assert_nil ClassInfoTestSubSubclass.get_class_info.all_options[:option4] 207 | end 208 | 209 | def test_current_options_in_superclass 210 | assert_equal({:option3 => :asdf}, ClassInfoTestRecord.get_class_info.current_options) 211 | end 212 | 213 | def test_current_options_in_subclass 214 | assert_equal({:option1 => :quack, :option4 => :fdsa}, ClassInfoTestSubclass.get_class_info.current_options) 215 | end 216 | 217 | def test_two_features_in_the_same_model 218 | assert_equal({:option1 => :badger}, ClassInfoTestSubclass2.test2_class_info.all_options) 219 | assert_equal({:option1 => :badger}, ClassInfoTestSubSubclass.test2_class_info.all_options) 220 | end 221 | 222 | def test_the_same_feature_in_two_models 223 | assert_equal({:option1 => :okapi, :option3 => :kangaroo}, ClassInfoTest2Record.test2_class_info.all_options) 224 | end 225 | 226 | def test_method_renamed 227 | assert_equal 'kangaroo', ClassInfoTest2Record.test2_class_info.method(:option3) 228 | assert_equal 'bounce', ClassInfoTest2Record.test2_class_info.get(ClassInfoTest2Record.find(1), :option3) 229 | end 230 | 231 | def test_database_column_renamed 232 | assert_equal 'okapi', ClassInfoTest2Record.test2_class_info.method(:option1) 233 | assert_equal 'OKAPI!', ClassInfoTest2Record.test2_class_info.get(ClassInfoTest2Record.find(1), :option1) 234 | end 235 | 236 | def test_method_not_renamed 237 | assert_equal 'option2', ClassInfoTest2Record.test2_class_info.method(:option2) 238 | assert_equal 'this is option2', ClassInfoTest2Record.test2_class_info.get(ClassInfoTest2Record.find(1), :option2) 239 | end 240 | 241 | def test_method_on_nil_object 242 | assert_nil ClassInfoTest2Record.test2_class_info.get(nil, :option2) 243 | end 244 | 245 | def test_method_not_found_via_class_info 246 | assert_nil ClassInfoTest2Record.test2_class_info.get(ClassInfoTest2Record.find(1), :this_method_does_not_exist) 247 | end 248 | 249 | def test_inherited_to_empty_subclass 250 | assert_not_nil ClassInfoTestEmptySubclass.new.get_class_info 251 | assert_equal ClassInfoTestEmptySubclass.new.get_class_info, ClassInfoTestRecord.new.get_class_info 252 | end 253 | end 254 | -------------------------------------------------------------------------------- /test/connection_adapter_ext_test.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), 'test_helper.rb') 2 | 3 | class ConnectionAdapterExtTest < Test::Unit::TestCase 4 | 5 | # Don't run these tests in database transactions. 6 | def setup 7 | end 8 | def teardown 9 | end 10 | 11 | def using_database(database_type) 12 | if database_type.to_sym == database_used_for_testing 13 | # If the test is for the main database type of this test suite, just run it 14 | yield 15 | 16 | elsif test_in_all_databases 17 | # Run the test having connected to the requested database type, or skip it 18 | # if we're not trying to test all database types 19 | begin 20 | ActiveRecord::Base.establish_connection(TEST_DB_CONFIG[database_type.to_sym]) 21 | yield 22 | ensure 23 | connect_to_testing_database 24 | end 25 | end 26 | end 27 | 28 | def test_conditional_function_as_mysql 29 | using_database :mysql do 30 | assert_equal "IF(true, foo, bar)", Invoicing::ConnectionAdapterExt.conditional_function('true', 'foo', 'bar') 31 | end 32 | end 33 | 34 | def test_conditional_function_as_postgresql 35 | using_database :postgresql do 36 | assert_equal "CASE WHEN true THEN foo ELSE bar END", 37 | Invoicing::ConnectionAdapterExt.conditional_function('true', 'foo', 'bar') 38 | end 39 | end 40 | 41 | def test_conditional_function_as_sqlite3 42 | using_database :sqlite3 do 43 | assert_raise RuntimeError do 44 | Invoicing::ConnectionAdapterExt.conditional_function('true', 'foo', 'bar') 45 | end 46 | end 47 | end 48 | 49 | def test_group_by_all_columns_as_mysql 50 | using_database :mysql do 51 | assert_equal "`ledger_item_records`.`id2`", 52 | Invoicing::ConnectionAdapterExt.group_by_all_columns(MyLedgerItem) 53 | end 54 | end 55 | 56 | def test_group_by_all_columns_as_postgresql 57 | using_database :postgresql do 58 | assert_equal( 59 | '"ledger_item_records"."id2", "ledger_item_records"."type2", "ledger_item_records"."sender_id2", ' + 60 | '"ledger_item_records"."recipient_id2", "ledger_item_records"."identifier2", ' + 61 | '"ledger_item_records"."issue_date2", "ledger_item_records"."currency2", ' + 62 | '"ledger_item_records"."total_amount2", "ledger_item_records"."tax_amount2", ' + 63 | '"ledger_item_records"."status2", "ledger_item_records"."period_start2", ' + 64 | '"ledger_item_records"."period_end2", "ledger_item_records"."uuid2", ' + 65 | '"ledger_item_records"."due_date2", "ledger_item_records"."created_at", ' + 66 | '"ledger_item_records"."updated_at"', 67 | Invoicing::ConnectionAdapterExt.group_by_all_columns(MyLedgerItem)) 68 | end 69 | end 70 | 71 | def test_group_by_all_columns_as_sqlite3 72 | using_database :sqlite3 do 73 | assert_raise RuntimeError do 74 | Invoicing::ConnectionAdapterExt.group_by_all_columns(MyLedgerItem) 75 | end 76 | end 77 | end 78 | 79 | end 80 | -------------------------------------------------------------------------------- /test/currency_value_test.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require File.join(File.dirname(__FILE__), 'test_helper.rb') 4 | 5 | # Test extending the default list of currency codes: include the Zimbabwe Dollar. 6 | # This also tests rounding and seriously large numbers. -- Sorry, you shouldn't make 7 | # jokes about this sort of thing. The people are suffering badly. 8 | Invoicing::CurrencyValue::CURRENCIES['ZWD'] = {:symbol => 'ZW$', :round => 5_000_000} 9 | 10 | class CurrencyValueTest < Test::Unit::TestCase 11 | 12 | class CurrencyValueRecord < ActiveRecord::Base 13 | validates_numericality_of :amount 14 | acts_as_currency_value :amount, :tax_amount, :currency => 'currency_code' 15 | end 16 | 17 | # In Finland and the Netherlands, Euro amounts are commonly rounded to the nearest 5 cents. 18 | class EurosInFinlandRecord < ActiveRecord::Base 19 | set_table_name 'no_currency_column_records' 20 | acts_as_currency_value :amount, :currency_code => 'EUR', :round => 0.05, :space => true 21 | end 22 | 23 | def test_format_small_number 24 | assert_equal "€0.02", CurrencyValueRecord.find(2).tax_amount_formatted 25 | end 26 | 27 | def test_format_thousands_separators 28 | assert_equal "€98,765,432.00", CurrencyValueRecord.find(2).amount_formatted 29 | end 30 | 31 | def test_format_no_decimal_point 32 | assert_equal "¥8,888", CurrencyValueRecord.find(4).amount_formatted 33 | end 34 | 35 | def test_format_suffix_unit 36 | assert_equal "5,432.00 元", CurrencyValueRecord.find(3).amount_formatted 37 | end 38 | 39 | def test_format_unknown_currency 40 | assert_equal "123.00 XXX", CurrencyValueRecord.find(5).amount_formatted 41 | end 42 | 43 | def test_format_with_custom_currency 44 | record = CurrencyValueRecord.new(:currency_code => 'ZWD', :amount => BigDecimal('50000000000')) 45 | assert_equal "ZW$50,000,000,000", record.amount_formatted # price of 1 egg on 18 July 2008 46 | end 47 | 48 | def test_format_without_currency_column 49 | assert_equal "€ 95.15", EurosInFinlandRecord.find(1).amount_formatted 50 | end 51 | 52 | def test_format_with_options 53 | assert_equal "0.02 €", CurrencyValueRecord.find(2).tax_amount_formatted(:suffix => true) 54 | end 55 | 56 | def test_format_custom_value 57 | assert_equal "€1,357.90", CurrencyValueRecord.find(2).format_currency_value(BigDecimal('1357.9')) 58 | end 59 | 60 | def test_format_negative_value_with_minus 61 | assert_equal "−€1,357.90", CurrencyValueRecord.find(2).format_currency_value(BigDecimal('-1357.9')) 62 | end 63 | 64 | def test_format_negative_value_with_hyphen 65 | assert_equal "-€1,357.90", CurrencyValueRecord.find(2).format_currency_value(BigDecimal('-1357.9'), :negative => :hyphen) 66 | end 67 | 68 | def test_format_negative_value_with_brackets 69 | assert_equal "(€1,357.90)", CurrencyValueRecord.find(2).format_currency_value(BigDecimal('-1357.9'), :negative => :brackets) 70 | end 71 | 72 | def test_load_from_database_and_format 73 | assert_equal BigDecimal('123.45'), CurrencyValueRecord.find(1).amount 74 | assert_equal "£123.45", CurrencyValueRecord.find(1).amount_formatted 75 | end 76 | 77 | def test_new_record_from_string_and_format 78 | record = CurrencyValueRecord.new(:amount => '44.44', :currency_code => 'USD') 79 | assert_equal BigDecimal('44.44'), record.amount 80 | assert_equal "$44.44", record.amount_formatted 81 | end 82 | 83 | def test_new_record_from_big_decimal_and_format 84 | record = CurrencyValueRecord.new(:amount => BigDecimal('3.33'), :currency_code => 'USD') 85 | assert_equal BigDecimal('3.33'), record.amount 86 | assert_equal "$3.33", record.amount_formatted 87 | end 88 | 89 | def test_standalone_formatting 90 | assert_equal "12.00 €", Invoicing::CurrencyValue::Formatter.format_value(:eur, 12, :suffix => true) 91 | end 92 | 93 | def test_standalone_currency_info 94 | assert_equal({:code => 'USD', :symbol => '$', :round => 0.01, :suffix => false, 95 | :space => false, :digits => 2}, Invoicing::CurrencyValue::Formatter.currency_info(:USD)) 96 | end 97 | 98 | def test_assign_float_to_new_record_and_format 99 | record = CurrencyValueRecord.new 100 | record.amount = 44.44 101 | record.currency_code = 'USD' 102 | assert_equal BigDecimal('44.44'), record.amount 103 | assert_equal "$44.44", record.amount_formatted 104 | end 105 | 106 | def test_assign_to_new_record_omitting_currency 107 | record = CurrencyValueRecord.new 108 | record.amount = 44.44 109 | assert_equal BigDecimal('44.44'), record.amount 110 | assert_equal "44.44", record.amount_formatted 111 | end 112 | 113 | def test_assign_nothing_to_new_record_with_numericality_validation 114 | record = CurrencyValueRecord.new(:currency_code => 'USD') 115 | assert_nil record.amount 116 | assert_equal '', record.amount_formatted 117 | assert !record.valid? 118 | end 119 | 120 | def test_assign_nothing_to_new_record_without_numericality_validation 121 | record = CurrencyValueRecord.new(:amount => 1, :currency_code => 'USD') 122 | assert_nil record.tax_amount 123 | assert_equal '', record.tax_amount_formatted 124 | assert record.valid? 125 | record.save! 126 | assert_equal([{'amount' => '1.0000', 'tax_amount' => nil}], 127 | ActiveRecord::Base.connection.select_all("SELECT amount, tax_amount FROM currency_value_records WHERE id=#{record.id}")) 128 | end 129 | 130 | def test_assign_invalid_value_to_new_record_with_numericality_validation 131 | record = CurrencyValueRecord.new(:amount => 'plonk', :currency_code => 'USD') 132 | assert_equal BigDecimal('0.00'), record.amount 133 | assert_equal 'plonk', record.amount_before_type_cast 134 | assert_equal '', record.amount_formatted 135 | assert !record.valid? 136 | end 137 | 138 | def test_assign_invalid_value_to_new_record_without_numericality_validation 139 | record = CurrencyValueRecord.new(:amount => 1, :tax_amount => 'plonk', :currency_code => 'USD') 140 | assert_equal BigDecimal('0.00'), record.tax_amount 141 | assert_equal 'plonk', record.tax_amount_before_type_cast 142 | assert_equal '', record.tax_amount_formatted 143 | assert record.valid? 144 | record.save! 145 | assert_equal([{'amount' => '1.0000', 'tax_amount' => '0.0000'}], 146 | ActiveRecord::Base.connection.select_all("SELECT amount, tax_amount FROM currency_value_records WHERE id=#{record.id}")) 147 | end 148 | 149 | def test_overwrite_existing_record_with_valid_value 150 | record = CurrencyValueRecord.find(4) 151 | record.amount = '12.34' 152 | record.currency_code = 'EUR' 153 | assert_equal BigDecimal('12.34'), record.amount 154 | assert_equal '12.34', record.amount_before_type_cast 155 | assert_equal "€12.34", record.amount_formatted 156 | record.save! 157 | assert_equal([{'amount' => '12.3400', 'currency_code' => 'EUR'}], 158 | ActiveRecord::Base.connection.select_all("SELECT amount, currency_code FROM currency_value_records WHERE id=#{record.id}")) 159 | end 160 | 161 | def test_overwrite_existing_record_with_nil 162 | record = CurrencyValueRecord.find(4) 163 | record.tax_amount = nil 164 | assert_nil record.tax_amount 165 | assert_nil record.tax_amount_before_type_cast 166 | assert_equal '', record.tax_amount_formatted 167 | record.save! 168 | assert_equal([{'amount' => '8888.0000', 'tax_amount' => nil, 'currency_code' => 'JPY'}], 169 | ActiveRecord::Base.connection.select_all("SELECT amount, tax_amount, currency_code FROM currency_value_records WHERE id=#{record.id}")) 170 | end 171 | 172 | def test_rounding_on_new_record_with_currency_column 173 | record = CurrencyValueRecord.new(:amount => '1234.5678', :currency_code => 'JPY') 174 | assert_equal BigDecimal('1235'), record.amount 175 | assert_equal '1234.5678', record.amount_before_type_cast 176 | record.save! 177 | assert_equal([{'amount' => '1235.0000'}], 178 | ActiveRecord::Base.connection.select_all("SELECT amount FROM currency_value_records WHERE id=#{record.id}")) 179 | end 180 | 181 | def test_rounding_on_overwriting_record_with_currency_column 182 | record = CurrencyValueRecord.find(1) 183 | record.amount = 10.0/3.0 184 | assert_equal BigDecimal('3.33'), record.amount 185 | assert_equal 10.0/3.0, record.amount_before_type_cast 186 | record.save! 187 | assert_equal([{'amount' => '3.3300'}], 188 | ActiveRecord::Base.connection.select_all("SELECT amount FROM currency_value_records WHERE id=1")) 189 | end 190 | 191 | def test_rounding_on_new_record_with_default_currency 192 | record = EurosInFinlandRecord.new(:amount => '1234.5678') 193 | assert_equal BigDecimal('1234.55'), record.amount 194 | assert_equal '1234.5678', record.amount_before_type_cast 195 | record.save! 196 | assert_equal([{'amount' => '1234.5500'}], 197 | ActiveRecord::Base.connection.select_all("SELECT amount FROM no_currency_column_records WHERE id=#{record.id}")) 198 | end 199 | 200 | def test_rounding_on_overwriting_record_with_default_currency 201 | record = EurosInFinlandRecord.find(1) 202 | record.amount = '98.7654321' 203 | assert_equal BigDecimal('98.75'), record.amount 204 | assert_equal '98.7654321', record.amount_before_type_cast 205 | record.save! 206 | assert_equal([{'amount' => '98.7500'}], 207 | ActiveRecord::Base.connection.select_all("SELECT amount FROM no_currency_column_records WHERE id=1")) 208 | end 209 | end 210 | -------------------------------------------------------------------------------- /test/find_subclasses_test.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), 'test_helper.rb') 2 | 3 | # Associated with TestBaseclass 4 | 5 | class FindSubclassesAssociate < ActiveRecord::Base 6 | end 7 | 8 | 9 | # Primary hierarchy of classes for testing. 10 | 11 | class TestBaseclass < ActiveRecord::Base 12 | set_table_name 'find_subclasses_records' 13 | set_inheritance_column 'type_name' # usually left as default 'type'. rename to test renaming 14 | belongs_to :associate, :foreign_key => 'associate_id', :class_name => 'FindSubclassesAssociate' 15 | named_scope :with_coolness, lambda{|factor| {:conditions => {:coolness_factor => factor}}} 16 | extend Invoicing::FindSubclasses 17 | def self.coolness_factor; 3; end 18 | end 19 | 20 | class TestSubclass < TestBaseclass 21 | 22 | end 23 | 24 | class TestSubSubclass < TestSubclass 25 | def self.coolness_factor; 5; end 26 | end 27 | 28 | module TestModule 29 | class TestInsideModuleSubclass < TestBaseclass 30 | def self.coolness_factor; nil; end 31 | end 32 | end 33 | 34 | class TestOutsideModuleSubSubclass < TestModule::TestInsideModuleSubclass 35 | def self.coolness_factor; 999; end 36 | end 37 | 38 | 39 | # This class' table contains non-existent subclass names, to test errors 40 | 41 | class SomeSillySuperclass < ActiveRecord::Base 42 | extend Invoicing::FindSubclasses 43 | set_table_name 'find_subclasses_non_existent' 44 | end 45 | 46 | 47 | ##################### 48 | 49 | class FindSubclassesTest < Test::Unit::TestCase 50 | 51 | def test_known_subclasses 52 | # All subclasses of TestBaseclass except for TestSubclassNotInDatabase 53 | expected = ['TestBaseclass', 'TestModule::TestInsideModuleSubclass', 'TestOutsideModuleSubSubclass', 54 | 'TestSubSubclass', 'TestSubclass', 'TestSubclassInAnotherFile'] 55 | assert_equal expected, TestBaseclass.known_subclasses.map{|cls| cls.name}.sort 56 | end 57 | 58 | def test_known_subclasses_for_subtype 59 | expected = ['TestSubSubclass', 'TestSubclass'] 60 | assert_equal expected, TestSubclass.known_subclasses.map{|cls| cls.name}.sort 61 | end 62 | 63 | def test_error_when_unknown_type_is_encountered 64 | assert_raise ActiveRecord::SubclassNotFound do 65 | SomeSillySuperclass.known_subclasses 66 | end 67 | end 68 | 69 | def test_class_method_condition_in_find 70 | assert_equal [1, 2, 4], TestBaseclass.all(:conditions => {:coolness_factor => 3}).map{|r| r.id}.sort 71 | end 72 | 73 | def test_class_method_condition_in_named_scope 74 | assert_equal [6], TestBaseclass.with_coolness(999).map{|r| r.id} 75 | end 76 | 77 | def test_class_method_condition_combined_with_column_condition_as_string_list 78 | assert_equal [2, 4], TestBaseclass.with_coolness(3).all(:conditions => ["value LIKE ?", 'B%']).map{|r| r.id}.sort 79 | end 80 | 81 | def test_class_method_condition_combined_with_column_condition_as_hash 82 | assert_equal [1], TestBaseclass.scoped(:conditions => {:value => 'Mooo!', :coolness_factor => 3}).all.map{|r| r.id} 83 | end 84 | 85 | def test_class_method_condition_combined_with_column_condition_on_joined_table_expressed_as_string 86 | conditions = {'find_subclasses_associates.value' => 'Cool stuff', 'find_subclasses_records.coolness_factor' => 3} 87 | assert_equal [1], TestBaseclass.all(:joins => :associate, :conditions => conditions).map{|r| r.id} 88 | end 89 | 90 | def test_class_method_condition_combined_with_column_condition_on_joined_table_expressed_as_hash 91 | conditions = {:find_subclasses_associates => {:value => 'Cool stuff'}, 92 | :find_subclasses_records => {:coolness_factor => 3}} 93 | assert_equal [1], TestBaseclass.all(:joins => :associate, :conditions => conditions).map{|r| r.id} 94 | end 95 | 96 | def test_class_method_condition_with_same_table_name 97 | conditions = {'find_subclasses_records.value' => 'Baaa!', 'find_subclasses_records.coolness_factor' => 3} 98 | assert_equal [2, 4], TestBaseclass.all(:conditions => conditions).map{|r| r.id}.sort 99 | end 100 | 101 | def test_class_method_condition_with_list_of_alternatives 102 | assert_equal [3, 6], TestBaseclass.all(:conditions => {:coolness_factor => [5, 999]}).map{|r| r.id}.sort 103 | end 104 | 105 | def test_class_method_condition_with_range_of_alternatives 106 | assert_equal [1, 2, 3, 4, 6], TestBaseclass.all(:conditions => {:coolness_factor => 1..1000}).map{|r| r.id}.sort 107 | end 108 | 109 | def test_class_method_condition_invoked_on_subclass 110 | assert_equal [2], TestSubclass.with_coolness(3).all.map{|r| r.id} 111 | end 112 | 113 | def test_class_method_condition_false_type_coercion 114 | assert_equal [5], TestBaseclass.find(:all, :conditions => {:coolness_factor => false}).map{|r| r.id} 115 | end 116 | 117 | def test_class_method_condition_true_type_coercion 118 | assert_equal [1, 2, 3, 4, 6], TestBaseclass.all(:conditions => {:coolness_factor => true}).map{|r| r.id}.sort 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /test/fixtures/README: -------------------------------------------------------------------------------- 1 | The SQL files in this folder are executed once on the current ActiveRecord database connection 2 | before the tests are run. Use them to set up a database schema and any contents (fixtures) 3 | required by the tests. Tests are run in transactions and rolled back, so the database should 4 | be restored back to the state defined in these files after each test. 5 | 6 | It's important that all tables are created with option ENGINE=InnoDB, otherwise MySQL creates 7 | MyISAM tables which do not support transactions. 8 | -------------------------------------------------------------------------------- /test/fixtures/cached_record.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS cached_records; 2 | 3 | CREATE TABLE cached_records ( 4 | id2 int primary key auto_increment, 5 | value varchar(255) 6 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; 7 | 8 | INSERT INTO cached_records(id2, value) values(1, 'One'), (2, 'Two'); 9 | 10 | ALTER SEQUENCE cached_records_id2_seq restart 1000; 11 | 12 | 13 | DROP TABLE IF EXISTS refers_to_cached_records; 14 | 15 | CREATE TABLE refers_to_cached_records ( 16 | id int primary key auto_increment, 17 | cached_record_id int 18 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; 19 | 20 | INSERT INTO refers_to_cached_records(id, cached_record_id) values(1, 1), (2, 1), (3, NULL); 21 | 22 | ALTER SEQUENCE refers_to_cached_records_id_seq restart 1000; 23 | -------------------------------------------------------------------------------- /test/fixtures/class_info.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS class_info_test_records; 2 | 3 | CREATE TABLE class_info_test_records ( 4 | id int primary key auto_increment, 5 | value int, 6 | type varchar(255) 7 | ); 8 | 9 | INSERT INTO class_info_test_records (id, value, type) values 10 | (1, 2, 'ClassInfoTestRecord'), 11 | (2, 3, 'ClassInfoTestSubclass'), 12 | (3, 3, 'ClassInfoTestSubclass2'), 13 | (4, 3, 'ClassInfoTestSubSubclass'); 14 | 15 | ALTER SEQUENCE class_info_test_records_id_seq restart 1000; 16 | 17 | 18 | DROP TABLE IF EXISTS class_info_test2_records; 19 | 20 | CREATE TABLE class_info_test2_records ( 21 | id int primary key auto_increment, 22 | value int, 23 | okapi varchar(255) 24 | ); 25 | 26 | INSERT INTO class_info_test2_records(id, value, okapi) values(1, 1, 'OKAPI!'); 27 | 28 | ALTER SEQUENCE class_info_test2_records_id_seq restart 1000; 29 | -------------------------------------------------------------------------------- /test/fixtures/currency_value.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS currency_value_records; 2 | 3 | CREATE TABLE currency_value_records ( 4 | id int primary key auto_increment, 5 | currency_code varchar(3), 6 | amount decimal(20,4), 7 | tax_amount decimal(20,4) 8 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; 9 | 10 | INSERT INTO currency_value_records(id, currency_code, amount, tax_amount) values 11 | (1, 'GBP', 123.45, NULL), 12 | (2, 'EUR', 98765432, 0.02), 13 | (3, 'CNY', 5432, 0), 14 | (4, 'JPY', 8888, 123), 15 | (5, 'XXX', 123, NULL); 16 | 17 | ALTER SEQUENCE currency_value_records_id_seq restart 1000; 18 | 19 | 20 | DROP TABLE IF EXISTS no_currency_column_records; 21 | 22 | CREATE TABLE no_currency_column_records ( 23 | id int primary key auto_increment, 24 | amount decimal(20,4) 25 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; 26 | 27 | INSERT INTO no_currency_column_records(id, amount) values(1, '95.15'); 28 | 29 | ALTER SEQUENCE no_currency_column_records_id_seq restart 1000; 30 | -------------------------------------------------------------------------------- /test/fixtures/find_subclasses.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS find_subclasses_records; 2 | 3 | CREATE TABLE find_subclasses_records ( 4 | id int primary key auto_increment, 5 | value varchar(255), 6 | type_name varchar(255), 7 | associate_id int 8 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; 9 | 10 | INSERT INTO find_subclasses_records(id, value, associate_id, type_name) values 11 | (1, 'Mooo!', 1, 'TestBaseclass'), 12 | (2, 'Baaa!', NULL, 'TestSubclass'), 13 | (3, 'Mooo!', NULL, 'TestSubSubclass'), 14 | (4, 'Baaa!', NULL, 'TestSubclassInAnotherFile'), 15 | (5, 'Mooo!', 1, 'TestModule::TestInsideModuleSubclass'), 16 | (6, 'Baaa!', 1, 'TestOutsideModuleSubSubclass'); 17 | 18 | ALTER SEQUENCE find_subclasses_records_id_seq restart 1000; 19 | 20 | 21 | DROP TABLE IF EXISTS find_subclasses_associates; 22 | 23 | CREATE TABLE find_subclasses_associates ( 24 | id int primary key auto_increment, 25 | value varchar(255) 26 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; 27 | 28 | INSERT INTO find_subclasses_associates (id, value) values(1, 'Cool stuff'); 29 | 30 | ALTER SEQUENCE find_subclasses_associates_id_seq restart 1000; 31 | 32 | 33 | DROP TABLE IF EXISTS find_subclasses_non_existent; 34 | 35 | CREATE TABLE find_subclasses_non_existent ( 36 | id int primary key auto_increment, 37 | value varchar(255), 38 | type varchar(255) 39 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; 40 | 41 | INSERT INTO find_subclasses_non_existent(id, value, type) values(1, 'Badger', 'SurelyThereIsNoClassWithThisName'); 42 | 43 | ALTER SEQUENCE find_subclasses_non_existent_id_seq restart 1000; 44 | -------------------------------------------------------------------------------- /test/fixtures/ledger_item.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS ledger_item_records; 2 | 3 | CREATE TABLE ledger_item_records ( 4 | id2 int primary key auto_increment, 5 | type2 varchar(255) not null, 6 | sender_id2 int, 7 | recipient_id2 int, 8 | identifier2 varchar(255), 9 | issue_date2 datetime, 10 | currency2 varchar(5), 11 | total_amount2 decimal(20,4), 12 | tax_amount2 decimal(20,4), 13 | status2 varchar(100), 14 | period_start2 datetime, 15 | period_end2 datetime, 16 | uuid2 varchar(40), 17 | due_date2 datetime, 18 | created_at datetime, 19 | updated_at datetime 20 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; 21 | 22 | 23 | INSERT INTO ledger_item_records 24 | (id2, type2, sender_id2, recipient_id2, identifier2, issue_date2, currency2, total_amount2, tax_amount2, status2, period_start2, period_end2, uuid2, due_date2, created_at, updated_at) values 25 | (1, 'MyInvoice', 1, 2, '1', '2008-06-30', 'GBP', 315.00, 15.00, 'closed', '2008-06-01', '2008-07-01', '30f4f680-d1b9-012b-48a5-0017f22d32c0', '2008-07-30', '2008-06-02 12:34:00', '2008-07-01 00:00:00'), 26 | (2, 'InvoiceSubtype', 2, 1, '12-ASDF', '2009-01-01', 'GBP', 141.97, 18.52, 'closed', '2008-01-01', '2009-01-01', 'fe4d20a0-d1b9-012b-48a5-0017f22d32c0', '2009-01-31', '2008-12-25 00:00:00', '2008-12-26 00:00:00'), 27 | (3, 'MyCreditNote', 1, 2, 'putain!', '2008-07-13', 'GBP', -57.50, -7.50, 'closed', '2008-06-01', '2008-07-01', '671a05d0-d1ba-012b-48a5-0017f22d32c0', NULL, '2008-07-13 09:13:14', '2008-07-13 09:13:14'), 28 | (4, 'MyPayment', 1, 2, '14BC4E0F', '2008-07-06', 'GBP', 256.50, 0.00, 'cleared', NULL, NULL, 'cfdf2ae0-d1ba-012b-48a5-0017f22d32c0', NULL, '2008-07-06 01:02:03', '2008-07-06 02:03:04'), 29 | (5, 'MyLedgerItem', 2, 3, NULL, '2007-04-23', 'USD', 432.10, NULL, 'closed', NULL, NULL, 'f6d6a700-d1ae-012b-48a5-0017f22d32c0', '2011-02-27', '2008-01-01 00:00:00', '2008-01-01 00:00:00'), 30 | (6, 'CorporationTaxLiability', 4, 1, 'OMGWTFBBQ', '2009-01-01', 'GBP', 666666.66, NULL, 'closed', '2008-01-01', '2009-01-01', '7273c000-d1bb-012b-48a5-0017f22d32c0', '2009-04-23', '2009-01-23 00:00:00', '2009-01-23 00:00:00'), 31 | (7, 'MyPayment', 1, 2, 'nonsense', '2009-01-23', 'GBP', 1000000.00, 0.00, 'failed', NULL, NULL, 'af488310-d1bb-012b-48a5-0017f22d32c0', NULL, '2009-01-23 00:00:00', '2009-01-23 00:00:00'), 32 | (8, 'MyPayment', 1, 2, '1quid', '2008-12-23', 'GBP', 1.00, 0.00, 'pending', NULL, NULL, 'df733560-d1bb-012b-48a5-0017f22d32c0', NULL, '2009-12-23 00:00:00', '2009-12-23 00:00:00'), 33 | (9, 'MyInvoice', 1, 2, '9', '2009-01-23', 'GBP', 11.50, 1.50, 'open', '2009-01-01', '2008-02-01', 'e5b0dac0-d1bb-012b-48a5-0017f22d32c0', '2009-02-01', '2009-12-23 00:00:00', '2009-12-23 00:00:00'), 34 | (10,'MyInvoice', 1, 2, 'a la con', '2009-01-23', 'GBP', 432198.76, 4610.62, 'cancelled', '2008-12-01', '2009-01-01', 'eb167b10-d1bb-012b-48a5-0017f22d32c0', NULL, '2009-12-23 00:00:00', '2009-12-23 00:00:00'), 35 | (11,'MyInvoice', 1, 2, 'no_lines', '2009-01-24', 'GBP', NULL, NULL, 'closed', '2009-01-23', '2009-01-24', '9ed54a00-d99f-012b-592c-0017f22d32c0', '2009-01-25', '2009-01-24 23:59:59', '2009-01-24 23:59:59'); 36 | 37 | -- Invoice 10 is set to not add up correctly; total_amount is 0.01 too little to test error handling 38 | 39 | ALTER SEQUENCE ledger_item_records_id2_seq restart 1000; 40 | -------------------------------------------------------------------------------- /test/fixtures/line_item.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS line_item_records; 2 | 3 | CREATE TABLE line_item_records ( 4 | id2 int primary key auto_increment, 5 | type2 varchar(255), 6 | ledger_item_id2 int not null, 7 | net_amount2 decimal(20,4), 8 | tax_amount2 decimal(20,4), 9 | uuid2 varchar(40), 10 | tax_point2 datetime, 11 | tax_rate_id2 int, 12 | price_id2 int, 13 | quantity2 decimal(10,5), 14 | creator_id2 int, 15 | created_at datetime, 16 | updated_at datetime 17 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; 18 | 19 | 20 | -- Can you spot which make of computer I have? 21 | 22 | INSERT INTO line_item_records 23 | (id2, type2, ledger_item_id2, net_amount2, tax_amount2, uuid2, tax_point2, tax_rate_id2, price_id2, quantity2, creator_id2, created_at, updated_at) values 24 | (1, 'SuperLineItem', 1, 100.00, 15.00, '0cc659f0-cfac-012b-481d-0017f22d32c0', '2008-06-30', 1, 1, 1, 42, '2008-06-30 12:34:56', '2008-06-30 12:34:56'), 25 | (2, 'SubLineItem', 1, 200.00, 0, '0cc65e20-cfac-012b-481d-0017f22d32c0', '2008-06-25', 2, 2, 4, 42, '2008-06-30 21:43:56', '2008-06-30 21:43:56'), 26 | (3, 'OtherLineItem', 2, 123.45, 18.52, '0cc66060-cfac-012b-481d-0017f22d32c0', '2009-01-01', 1, NULL, 1, 43, '2008-12-25 00:00:00', '2008-12-26 00:00:00'), 27 | (4, 'UntaxedLineItem', 5, 432.10, NULL, '0cc662a0-cfac-012b-481d-0017f22d32c0', '2007-04-23', NULL, 3, NULL, 99, '2007-04-03 12:34:00', '2007-04-03 12:34:00'), 28 | (5, 'SuperLineItem', 3, -50.00, -7.50, 'eab28cf0-d1b4-012b-48a5-0017f22d32c0', '2008-07-13', 1, 1, 0.5, 42, '2008-07-13 09:13:14', '2008-07-13 09:13:14'), 29 | (6, 'OtherLineItem', 6, 666666.66, NULL, 'b5e66b50-d1b9-012b-48a5-0017f22d32c0', '2009-01-01', 3, NULL, 0, 666, '2009-01-23 00:00:00', '2009-01-23 00:00:00'), 30 | (7, 'SubLineItem', 9, 10.00, 1.50, '6f362040-d1be-012b-48a5-0017f22d32c0', '2009-01-31', 1, 1, 0.1, NULL, '2009-12-23 00:00:00', '2009-12-23 00:00:00'), 31 | (8, 'SubLineItem', 10, 427588.15, 4610.62, '3d12c020-d1bf-012b-48a5-0017f22d32c0', '2009-01-31', NULL, NULL, NULL, 42, '2009-12-23 00:00:00', '2009-12-23 00:00:00'); 32 | 33 | ALTER SEQUENCE line_item_records_id2_seq restart 1000; 34 | -------------------------------------------------------------------------------- /test/fixtures/price.sql: -------------------------------------------------------------------------------- 1 | -- For consistency with line_item.sql: 2 | -- 1 is 100.00 at tax rate 1 3 | -- 2 is 50.00 at tax rate 2 4 | -- 3 is 864.20 at tax rate null 5 | -------------------------------------------------------------------------------- /test/fixtures/tax_rate.sql: -------------------------------------------------------------------------------- 1 | -- For consistency with line_item.sql: 2 | -- 1 is at 15% 3 | -- 2 is at 0% 4 | -- 3 is not applicable (null) 5 | -------------------------------------------------------------------------------- /test/fixtures/taxable.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS taxable_records; 2 | 3 | CREATE TABLE taxable_records ( 4 | id int primary key auto_increment, 5 | currency_code varchar(3), 6 | amount decimal(20,4), 7 | gross_amount decimal(20,4), 8 | tax_factor decimal(10,9) 9 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; 10 | 11 | INSERT INTO taxable_records(id, currency_code, amount, gross_amount, tax_factor) values 12 | (1, 'GBP', 123.45, 141.09, 0.142857143); 13 | 14 | ALTER SEQUENCE taxable_records_id_seq restart 1000; 15 | -------------------------------------------------------------------------------- /test/fixtures/time_dependent.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS time_dependent_records; 2 | 3 | CREATE TABLE time_dependent_records ( 4 | id2 int primary key auto_increment, 5 | valid_from2 datetime not null, 6 | valid_until2 datetime, 7 | replaced_by_id2 int, 8 | value2 varchar(255) not null, 9 | is_default2 tinyint(1) not null 10 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; 11 | 12 | -- 2008 -> 2009 -> 2010 -> 2011 13 | -- 14 | -- 1 -> none 15 | -- 2 -> 3 -> 4 16 | -- 5 -> 3 17 | -- none -> 6* -> 7* -> none 18 | -- 8 -> 9* 19 | -- 10 20 | -- 21 | -- * = default 22 | 23 | INSERT INTO time_dependent_records(id2, valid_from2, valid_until2, replaced_by_id2, value2, is_default2) values 24 | ( 1, '2008-01-01 00:00:00', '2009-01-01 00:00:00', NULL, 'One', 0), -- false 25 | ( 2, '2008-01-01 00:00:00', '2009-01-01 00:00:00', 3, 'Two', 0), -- false 26 | ( 3, '2009-01-01 00:00:00', '2010-01-01 00:00:00', 4, 'Three', 0), -- false 27 | ( 4, '2010-01-01 00:00:00', NULL, NULL, 'Four', 0), -- false 28 | ( 5, '2008-01-01 00:00:00', '2009-01-01 00:00:00', 3, 'Five', 0), -- false 29 | ( 6, '2009-01-01 00:00:00', '2010-01-01 00:00:00', 7, 'Six', 1), -- true 30 | ( 7, '2010-01-01 00:00:00', '2011-01-01 00:00:00', NULL, 'Seven', 1), -- true 31 | ( 8, '2008-01-01 00:00:00', '2011-01-01 00:00:00', 9, 'Eight', 0), -- false 32 | ( 9, '2011-01-01 00:00:00', NULL, NULL, 'Nine', 1), -- true 33 | (10, '2008-01-01 00:00:00', NULL, NULL, 'Ten', 0); -- false 34 | 35 | ALTER SEQUENCE time_dependent_records_id2_seq restart 1000; 36 | -------------------------------------------------------------------------------- /test/ledger_item_test.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require File.join(File.dirname(__FILE__), 'test_helper.rb') 4 | 5 | ####### Helper stuff 6 | 7 | module LedgerItemMethods 8 | RENAMED_METHODS = { 9 | :id => :id2, :type => :type2, :sender_id => :sender_id2, :recipient_id => :recipient_id2, 10 | :sender_details => :sender_details2, :recipient_details => :recipient_details2, 11 | :identifier => :identifier2, :issue_date => :issue_date2, :currency => :currency2, 12 | :total_amount => :total_amount2, :tax_amount => :tax_amount2, :status => :status2, 13 | :description => :description2, :period_start => :period_start2, 14 | :period_end => :period_end2, :uuid => :uuid2, :due_date => :due_date2, 15 | :line_items => :line_items2 16 | } 17 | 18 | def user_id_to_details_hash(user_id) 19 | case user_id 20 | when 1, nil 21 | {:is_self => true, :name => 'Unlimited Limited', :contact_name => "Mr B. Badger", 22 | :address => "The Sett\n5 Badger Lane\n", :city => "Badgertown", :state => "", 23 | :postal_code => "Badger999", :country => "England", :country_code => "GB", 24 | :tax_number => "123456789"} 25 | when 2 26 | {:name => 'Lovely Customer Inc.', :contact_name => "Fred", 27 | :address => "The pasture", :city => "Mootown", :state => "Cow Kingdom", 28 | :postal_code => "MOOO", :country => "Scotland", :country_code => "GB", 29 | :tax_number => "987654321"} 30 | when 3 31 | {:name => 'I drink milk', :address => "Guzzle guzzle", :city => "Cheesetown", 32 | :postal_code => "12345", :country => "United States", :country_code => "US"} 33 | when 4 34 | {:name => 'The taxman', :address => "ALL YOUR EARNINGS\r\n\tARE BELONG TO US", 35 | :city => 'Cumbernauld', :state => 'North Lanarkshire', :postal_code => "", 36 | :country => 'United Kingdom'} 37 | end 38 | end 39 | 40 | def sender_details2 41 | user_id_to_details_hash(sender_id2) 42 | end 43 | 44 | def recipient_details2 45 | user_id_to_details_hash(recipient_id2) 46 | end 47 | 48 | def description2 49 | "#{type2} #{id2}" 50 | end 51 | end 52 | 53 | 54 | ####### Classes for use in the tests 55 | 56 | class MyLedgerItem < ActiveRecord::Base 57 | set_primary_key 'id2' 58 | set_inheritance_column 'type2' 59 | set_table_name 'ledger_item_records' 60 | include LedgerItemMethods 61 | acts_as_ledger_item RENAMED_METHODS 62 | has_many :line_items2, :class_name => 'SuperLineItem', :foreign_key => 'ledger_item_id2' 63 | end 64 | 65 | class MyInvoice < MyLedgerItem 66 | acts_as_ledger_item :subtype => :invoice 67 | end 68 | 69 | class InvoiceSubtype < MyInvoice 70 | end 71 | 72 | class MyCreditNote < MyLedgerItem 73 | acts_as_credit_note 74 | end 75 | 76 | class MyPayment < MyLedgerItem 77 | acts_as_payment 78 | end 79 | 80 | class CorporationTaxLiability < MyLedgerItem 81 | def self.debit_when_sent_by_self 82 | true 83 | end 84 | end 85 | 86 | class UUIDNotPresentLedgerItem < ActiveRecord::Base 87 | set_primary_key 'id2' 88 | set_inheritance_column 'type2' 89 | set_table_name 'ledger_item_records' 90 | include LedgerItemMethods 91 | 92 | def get_class_info 93 | ledger_item_class_info 94 | end 95 | end 96 | 97 | class OverwrittenMethodsNotPresentLedgerItem < ActiveRecord::Base 98 | set_primary_key 'id2' 99 | set_inheritance_column 'type2' 100 | set_table_name 'ledger_item_records' 101 | acts_as_invoice LedgerItemMethods::RENAMED_METHODS 102 | end 103 | 104 | 105 | ####### The actual tests 106 | 107 | class LedgerItemTest < Test::Unit::TestCase 108 | 109 | def test_total_amount_is_currency_value 110 | record = MyLedgerItem.find(5) 111 | assert_equal '$432.10', record.total_amount2_formatted 112 | end 113 | 114 | def test_tax_amount_is_currency_value 115 | record = MyInvoice.find(1) 116 | assert_equal '£15.00', record.tax_amount2_formatted 117 | end 118 | 119 | def test_total_amount_negative_debit 120 | record = MyLedgerItem.find(5) 121 | assert_equal '−$432.10', record.total_amount2_formatted(:debit => :negative, :self_id => record.recipient_id2) 122 | assert_equal '$432.10', record.total_amount2_formatted(:debit => :negative, :self_id => record.sender_id2) 123 | end 124 | 125 | def test_total_amount_negative_credit 126 | record = MyLedgerItem.find(5) 127 | assert_equal '−$432.10', record.total_amount2_formatted(:credit => :negative, :self_id => record.sender_id2) 128 | assert_equal '$432.10', record.total_amount2_formatted(:credit => :negative, :self_id => record.recipient_id2) 129 | end 130 | 131 | def test_net_amount 132 | assert_equal BigDecimal('300'), MyInvoice.find(1).net_amount 133 | end 134 | 135 | def test_net_amount_nil 136 | assert_nil MyInvoice.new.net_amount 137 | end 138 | 139 | def test_net_amount_formatted 140 | assert_equal '£300.00', MyInvoice.find(1).net_amount_formatted 141 | end 142 | 143 | def test_sent_by_nil_is_treated_as_self 144 | assert MyInvoice.find(1).sent_by?(nil) 145 | assert MyCreditNote.find(3).sent_by?(nil) 146 | end 147 | 148 | def test_received_by_nil_is_treated_as_self 149 | assert InvoiceSubtype.find(2).received_by?(nil) 150 | assert CorporationTaxLiability.find(6).received_by?(nil) 151 | end 152 | 153 | def test_invoice_from_self_is_debit 154 | record = MyInvoice.find(1) 155 | assert_kind_of MyInvoice, record 156 | assert record.debit?(1) 157 | assert record.debit?(nil) 158 | end 159 | 160 | def test_invoice_to_self_is_credit 161 | record = InvoiceSubtype.find(2) 162 | assert_kind_of MyInvoice, record 163 | assert !record.debit?(1) 164 | assert !record.debit?(nil) 165 | end 166 | 167 | def test_invoice_to_customer_is_seen_as_credit_by_customer 168 | assert !MyInvoice.find(1).debit?(2) 169 | end 170 | 171 | def test_invoice_from_supplier_is_seen_as_debit_by_supplier 172 | assert InvoiceSubtype.find(2).debit?(2) 173 | end 174 | 175 | def test_credit_note_from_self_is_debit 176 | record = MyCreditNote.find(3) 177 | assert_kind_of MyCreditNote, record 178 | assert record.debit?(nil) 179 | assert record.debit?(1) 180 | end 181 | 182 | def test_credit_note_to_customer_is_seen_as_credit_by_customer 183 | assert !MyCreditNote.find(3).debit?(2) 184 | end 185 | 186 | def test_payment_receipt_from_self_is_credit 187 | record = MyPayment.find(4) 188 | assert_kind_of MyPayment, record 189 | assert !record.debit?(1) 190 | assert !record.debit?(nil) 191 | end 192 | 193 | def test_payment_receipt_to_customer_is_seen_as_debit_by_customer 194 | assert MyPayment.find(4).debit?(2) 195 | end 196 | 197 | def test_cannot_determine_debit_status_for_uninvolved_party 198 | assert_raise ArgumentError do 199 | MyInvoice.find(1).debit?(3) 200 | end 201 | end 202 | 203 | def test_assign_uuid_to_new_record 204 | record = MyInvoice.new 205 | begin 206 | UUID 207 | uuid_gem_available = true 208 | rescue NameError 209 | uuid_gem_available = false 210 | end 211 | if uuid_gem_available 212 | assert_match /^[0-9a-f]{8}\-[0-9a-f]{4}\-[0-9a-f]{4}\-[0-9a-f]{4}\-[0-9a-f]{12}$/, record.uuid2 213 | else 214 | assert record.uuid2.blank? 215 | puts "Warning: uuid gem not installed -- not testing UUID generation" 216 | end 217 | end 218 | 219 | def test_uuid_gem_not_present 220 | begin 221 | real_uuid = Object.send(:remove_const, :UUID) rescue nil 222 | UUIDNotPresentLedgerItem.acts_as_ledger_item(LedgerItemMethods::RENAMED_METHODS) 223 | assert_nil UUIDNotPresentLedgerItem.new.get_class_info.uuid_generator 224 | ensure 225 | Object.send(:const_set, :UUID, real_uuid) unless real_uuid.nil? 226 | end 227 | end 228 | 229 | def test_must_overwrite_sender_details 230 | assert_raise RuntimeError do 231 | OverwrittenMethodsNotPresentLedgerItem.new.sender_details 232 | end 233 | end 234 | 235 | def test_must_overwrite_recipient_details 236 | assert_raise RuntimeError do 237 | OverwrittenMethodsNotPresentLedgerItem.new.recipient_details 238 | end 239 | end 240 | 241 | def test_must_provide_line_items_association 242 | assert_raise RuntimeError do 243 | OverwrittenMethodsNotPresentLedgerItem.new.line_items 244 | end 245 | end 246 | 247 | def test_calculate_total_amount_for_new_invoice 248 | invoice = MyInvoice.new(:currency2 => 'USD') 249 | invoice.line_items2 << SuperLineItem.new(:net_amount2 => 100, :tax_amount2 => 15) 250 | invoice.line_items2 << SubLineItem.new(:net_amount2 => 10) 251 | invoice.valid? 252 | assert_equal BigDecimal('125'), invoice.total_amount2 253 | assert_equal BigDecimal('15'), invoice.tax_amount2 254 | end 255 | 256 | def test_calculate_total_amount_for_updated_invoice 257 | invoice = MyInvoice.find(9) 258 | invoice.line_items2 << SuperLineItem.new(:net_amount2 => 10, :tax_amount2 => 1.5) 259 | invoice.save! 260 | assert_equal([{'total_amount2' => '23.0000', 'tax_amount2' => '3.0000'}], 261 | ActiveRecord::Base.connection.select_all("SELECT total_amount2, tax_amount2 FROM ledger_item_records WHERE id2=9")) 262 | end 263 | 264 | def test_calculate_total_amount_for_updated_line_item 265 | # This might occur while an invoice is still open 266 | invoice = MyInvoice.find(9) 267 | invoice.line_items2[0].net_amount2 = '20' 268 | invoice.line_items2[0].tax_amount2 = 3 269 | invoice.save! 270 | assert_equal([{'total_amount2' => '23.0000', 'tax_amount2' => '3.0000'}], 271 | ActiveRecord::Base.connection.select_all("SELECT total_amount2, tax_amount2 FROM ledger_item_records WHERE id2=9")) 272 | assert_equal BigDecimal('23'), invoice.total_amount2 273 | assert_equal BigDecimal('3'), invoice.tax_amount2 274 | end 275 | 276 | def test_do_not_overwrite_total_amount_for_payment_without_line_items 277 | payment = MyPayment.new :total_amount2 => 23.45 278 | payment.save! 279 | assert_equal([{'total_amount2' => '23.4500'}], 280 | ActiveRecord::Base.connection.select_all("SELECT total_amount2 FROM ledger_item_records WHERE id2=#{payment.id}")) 281 | end 282 | 283 | def test_line_items_error 284 | assert_raise RuntimeError do 285 | MyInvoice.find(1).line_items # not line_items2 286 | end 287 | end 288 | 289 | def test_find_invoice_subclasses 290 | assert_equal %w(InvoiceSubtype MyInvoice), MyLedgerItem.select_matching_subclasses(:is_invoice, true).map{|c| c.name}.sort 291 | end 292 | 293 | def test_find_credit_note_subclasses 294 | assert_equal %w(MyCreditNote), MyLedgerItem.select_matching_subclasses(:is_credit_note, true).map{|c| c.name} 295 | end 296 | 297 | def test_find_payment_subclasses 298 | assert_equal %w(MyPayment), MyLedgerItem.select_matching_subclasses(:is_payment, true).map{|c| c.name} 299 | end 300 | 301 | def test_account_summary 302 | summary = MyLedgerItem.account_summary(1, 2) 303 | assert_equal [:GBP], summary.keys 304 | assert_equal BigDecimal('257.50'), summary[:GBP].sales 305 | assert_equal BigDecimal('141.97'), summary[:GBP].purchases 306 | assert_equal BigDecimal('256.50'), summary[:GBP].sale_receipts 307 | assert_equal BigDecimal('0.00'), summary[:GBP].purchase_payments 308 | assert_equal BigDecimal('-140.97'), summary[:GBP].balance 309 | end 310 | 311 | def test_account_summary_with_scope 312 | conditions = ['issue_date2 >= ? AND issue_date2 < ?', DateTime.parse('2008-01-01'), DateTime.parse('2009-01-01')] 313 | summary = MyLedgerItem.scoped(:conditions => conditions).account_summary(1, 2) 314 | assert_equal BigDecimal('257.50'), summary[:GBP].sales 315 | assert_equal BigDecimal('0.00'), summary[:GBP].purchases 316 | assert_equal BigDecimal('256.50'), summary[:GBP].sale_receipts 317 | assert_equal BigDecimal('0.00'), summary[:GBP].purchase_payments 318 | assert_equal BigDecimal('1.00'), summary[:GBP].balance 319 | end 320 | 321 | def test_account_summaries 322 | summaries = MyLedgerItem.account_summaries(2) 323 | assert_equal [1, 3], summaries.keys 324 | assert_equal [:GBP], summaries[1].keys 325 | assert_equal BigDecimal('257.50'), summaries[1][:GBP].purchases 326 | assert_equal BigDecimal('141.97'), summaries[1][:GBP].sales 327 | assert_equal BigDecimal('256.50'), summaries[1][:GBP].purchase_payments 328 | assert_equal BigDecimal('0.00'), summaries[1][:GBP].sale_receipts 329 | assert_equal BigDecimal('140.97'), summaries[1][:GBP].balance 330 | assert_equal [:USD], summaries[3].keys 331 | assert_equal BigDecimal('0.00'), summaries[3][:USD].purchases 332 | assert_equal BigDecimal('0.00'), summaries[3][:USD].sales 333 | assert_equal BigDecimal('0.00'), summaries[3][:USD].purchase_payments 334 | assert_equal BigDecimal('432.10'), summaries[3][:USD].sale_receipts 335 | assert_equal BigDecimal('-432.10'), summaries[3][:USD].balance 336 | end 337 | 338 | def test_account_summaries_with_scope 339 | conditions = {:conditions => ['issue_date2 < ?', DateTime.parse('2008-07-01')]} 340 | summaries = MyLedgerItem.scoped(conditions).account_summaries(2) 341 | assert_equal [1, 3], summaries.keys 342 | assert_equal [:GBP], summaries[1].keys 343 | assert_equal BigDecimal('315.00'), summaries[1][:GBP].purchases 344 | assert_equal BigDecimal('0.00'), summaries[1][:GBP].sales 345 | assert_equal BigDecimal('0.00'), summaries[1][:GBP].purchase_payments 346 | assert_equal BigDecimal('0.00'), summaries[1][:GBP].sale_receipts 347 | assert_equal BigDecimal('-315.00'), summaries[1][:GBP].balance 348 | assert_equal [:USD], summaries[3].keys 349 | assert_equal BigDecimal('0.00'), summaries[3][:USD].purchases 350 | assert_equal BigDecimal('0.00'), summaries[3][:USD].sales 351 | assert_equal BigDecimal('0.00'), summaries[3][:USD].purchase_payments 352 | assert_equal BigDecimal('432.10'), summaries[3][:USD].sale_receipts 353 | assert_equal BigDecimal('-432.10'), summaries[3][:USD].balance 354 | end 355 | 356 | def test_account_summary_over_all_others 357 | summary = MyLedgerItem.account_summary(1) 358 | assert_equal [:GBP], summary.keys 359 | assert_equal BigDecimal('257.50'), summary[:GBP].sales 360 | assert_equal BigDecimal('666808.63'), summary[:GBP].purchases 361 | assert_equal BigDecimal('256.50'), summary[:GBP].sale_receipts 362 | assert_equal BigDecimal('0.00'), summary[:GBP].purchase_payments 363 | assert_equal BigDecimal('-666807.63'), summary[:GBP].balance 364 | end 365 | 366 | def test_account_summary_with_include_open_option 367 | summary = MyLedgerItem.account_summary(1, nil, :with_status => %w(open closed cleared)) 368 | assert_equal [:GBP], summary.keys 369 | assert_equal BigDecimal('269.00'), summary[:GBP].sales 370 | assert_equal BigDecimal('666808.63'), summary[:GBP].purchases 371 | assert_equal BigDecimal('256.50'), summary[:GBP].sale_receipts 372 | assert_equal BigDecimal('0.00'), summary[:GBP].purchase_payments 373 | assert_equal BigDecimal('-666796.13'), summary[:GBP].balance 374 | end 375 | 376 | def test_account_summary_to_s 377 | assert_equal "sales = £257.50; purchases = £666,808.63; sale_receipts = £256.50; " + 378 | "purchase_payments = £0.00; balance = −£666,807.63", MyLedgerItem.account_summary(1)[:GBP].to_s 379 | end 380 | 381 | def test_account_summary_formatting 382 | summary = MyLedgerItem.account_summary(1, 2) 383 | assert_equal [:GBP], summary.keys 384 | assert_equal '£257.50', summary[:GBP].sales_formatted 385 | assert_equal '£141.97', summary[:GBP].purchases_formatted 386 | assert_equal '£256.50', summary[:GBP].sale_receipts_formatted 387 | assert_equal '£0.00', summary[:GBP].purchase_payments_formatted 388 | assert_equal '−£140.97', summary[:GBP].balance_formatted 389 | end 390 | 391 | def test_account_summary_object_call_unknown_method 392 | assert_raise NoMethodError do 393 | MyLedgerItem.account_summary(1, 2)[:GBP].this_method_does_not_exist 394 | end 395 | end 396 | 397 | def test_sender_recipient_name_map_all 398 | assert_equal({1 => 'Unlimited Limited', 2 => 'Lovely Customer Inc.', 3 => 'I drink milk', 399 | 4 => 'The taxman'}, MyLedgerItem.sender_recipient_name_map([1,2,3,4])) 400 | end 401 | 402 | def test_sender_recipient_name_map_subset 403 | assert_equal({1 => 'Unlimited Limited', 3 => 'I drink milk'}, MyLedgerItem.sender_recipient_name_map([1,3])) 404 | end 405 | 406 | def test_sent_by_scope 407 | assert_equal [2,5], MyLedgerItem.sent_by(2).map{|i| i.id}.sort 408 | end 409 | 410 | def test_received_by_scope 411 | assert_equal [1,3,4,7,8,9,10,11], MyLedgerItem.received_by(2).map{|i| i.id}.sort 412 | end 413 | 414 | def test_sent_or_received_by_scope 415 | assert_equal [1,2,3,4,5,7,8,9,10,11], MyLedgerItem.sent_or_received_by(2).map{|i| i.id}.sort 416 | end 417 | 418 | def test_in_effect_scope 419 | assert_equal [1,2,3,4,5,6,7,8,9,10,11], MyLedgerItem.all.map{|i| i.id}.sort 420 | assert_equal [1,2,3,4,5,6,11], MyLedgerItem.in_effect.map{|i| i.id}.sort 421 | end 422 | 423 | def test_open_or_pending_scope 424 | assert_equal [8,9], MyLedgerItem.open_or_pending.map{|i| i.id}.sort 425 | end 426 | 427 | def test_due_at_scope 428 | assert_equal [1,3,4,7,8,10,11], MyLedgerItem.due_at(DateTime.parse('2009-01-30')).map{|i| i.id}.sort 429 | assert_equal [1,2,3,4,7,8,10,11], MyLedgerItem.due_at(DateTime.parse('2009-01-31')).map{|i| i.id}.sort 430 | end 431 | 432 | def test_sorted_scope 433 | assert_equal [5,1,4,3,8,2,6,7,9,10,11], MyLedgerItem.sorted(:issue_date).map{|i| i.id} 434 | end 435 | 436 | def test_sorted_scope_with_non_existent_column 437 | assert_equal [1,2,3,4,5,6,7,8,9,10,11], MyLedgerItem.sorted(:this_column_does_not_exist).map{|i| i.id} 438 | end 439 | 440 | def test_exclude_empty_invoices_scope 441 | assert_equal [1,2,3,4,5,6,7,8,9,10], MyLedgerItem.exclude_empty_invoices.map{|i| i.id}.sort 442 | end 443 | 444 | end 445 | -------------------------------------------------------------------------------- /test/line_item_test.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require File.join(File.dirname(__FILE__), 'test_helper.rb') 4 | 5 | ####### Helper stuff 6 | 7 | module LineItemMethods 8 | RENAMED_METHODS = { 9 | :id => :id2, :type => :type2, :ledger_item_id => :ledger_item_id2, 10 | :net_amount => :net_amount2, :tax_amount => :tax_amount2, 11 | :description => :description2, :uuid => :uuid2, :tax_point => :tax_point2, 12 | :tax_rate_id => :tax_rate_id2, :price_id => :price_id2, 13 | :quantity => :quantity2, :creator_id => :creator_id2, :ledger_item => :ledger_item2 14 | } 15 | 16 | def description2 17 | "moo" 18 | end 19 | end 20 | 21 | 22 | ####### Classes for use in the tests (also used by LedgerItemTest) 23 | 24 | class SuperLineItem < ActiveRecord::Base 25 | set_primary_key 'id2' 26 | set_inheritance_column 'type2' 27 | set_table_name 'line_item_records' 28 | include LineItemMethods 29 | acts_as_line_item RENAMED_METHODS 30 | belongs_to :ledger_item2, :class_name => 'MyLedgerItem', :foreign_key => 'ledger_item_id2' 31 | end 32 | 33 | class SubLineItem < SuperLineItem 34 | def description2 35 | "this is the SubLineItem" 36 | end 37 | end 38 | 39 | class OtherLineItem < SuperLineItem 40 | end 41 | 42 | class UntaxedLineItem < SuperLineItem 43 | end 44 | 45 | class UUIDNotPresentLineItem < ActiveRecord::Base 46 | set_primary_key 'id2' 47 | set_inheritance_column 'type2' 48 | set_table_name 'line_item_records' 49 | include LineItemMethods 50 | 51 | def get_class_info 52 | line_item_class_info 53 | end 54 | end 55 | 56 | class OverwrittenMethodsNotPresentLineItem < ActiveRecord::Base 57 | set_primary_key 'id2' 58 | set_inheritance_column 'type2' 59 | set_table_name 'line_item_records' 60 | acts_as_line_item LineItemMethods::RENAMED_METHODS 61 | end 62 | 63 | 64 | ####### The actual tests 65 | 66 | class LineItemTest < Test::Unit::TestCase 67 | 68 | def test_net_amount_is_currency_value 69 | assert_equal '$432.10', UntaxedLineItem.find(4).net_amount2_formatted 70 | end 71 | 72 | def test_tax_amount_is_currency_value 73 | assert_equal '£15.00', SuperLineItem.find(1).tax_amount2_formatted 74 | end 75 | 76 | def test_gross_amount 77 | assert_equal BigDecimal('115'), SuperLineItem.find(1).gross_amount 78 | end 79 | 80 | def test_gross_amount_formatted 81 | assert_equal '£115.00', SuperLineItem.find(1).gross_amount_formatted 82 | end 83 | 84 | def test_assign_uuid_to_new_record 85 | record = SuperLineItem.new 86 | begin 87 | UUID 88 | uuid_gem_available = true 89 | rescue NameError 90 | uuid_gem_available = false 91 | end 92 | if uuid_gem_available 93 | assert_match /^[0-9a-f]{8}\-[0-9a-f]{4}\-[0-9a-f]{4}\-[0-9a-f]{4}\-[0-9a-f]{12}$/, record.uuid2 94 | else 95 | assert record.uuid2.blank? 96 | puts "Warning: uuid gem not installed -- not testing UUID generation" 97 | end 98 | end 99 | 100 | def test_uuid_gem_not_present 101 | begin 102 | real_uuid = Object.send(:remove_const, :UUID) rescue nil 103 | UUIDNotPresentLineItem.acts_as_line_item(LineItemMethods::RENAMED_METHODS) 104 | assert_nil UUIDNotPresentLineItem.new.get_class_info.uuid_generator 105 | ensure 106 | Object.send(:const_set, :UUID, real_uuid) unless real_uuid.nil? 107 | end 108 | end 109 | 110 | def test_must_provide_ledger_item_association 111 | assert_raise RuntimeError do 112 | OverwrittenMethodsNotPresentLineItem.new.ledger_item 113 | end 114 | end 115 | 116 | def test_ledger_item_error 117 | assert_raise RuntimeError do 118 | SuperLineItem.find(1).ledger_item # not ledger_item2 119 | end 120 | end 121 | 122 | def test_currency 123 | assert_equal 'GBP', SubLineItem.find(2).currency 124 | end 125 | 126 | def test_in_effect_scope 127 | assert_equal [1,2,3,4,5,6,7,8], SuperLineItem.all.map{|i| i.id}.sort 128 | assert_equal [1,2,3,4,5,6], SuperLineItem.in_effect.map{|i| i.id}.sort 129 | end 130 | 131 | def test_sorted_scope 132 | assert_equal [4,2,1,5,3,6,7,8], SuperLineItem.sorted(:tax_point).map{|i| i.id} 133 | end 134 | 135 | def test_sorted_scope_with_non_existent_column 136 | assert_equal [1,2,3,4,5,6,7,8], SuperLineItem.sorted(:this_column_does_not_exist).map{|i| i.id} 137 | end 138 | 139 | end -------------------------------------------------------------------------------- /test/models/README: -------------------------------------------------------------------------------- 1 | This folder contains model object definitions used by the tests in the 'test' folder; 2 | it is included in ActiveSupport::Dependencies.load_paths but not 'require'd directly, 3 | so that we can test the effect which the ActiveSupport dependency resolution has on 4 | our code. -------------------------------------------------------------------------------- /test/models/test_subclass_in_another_file.rb: -------------------------------------------------------------------------------- 1 | # Used by find_subclasses_test.rb 2 | class TestSubclassInAnotherFile < TestBaseclass 3 | end -------------------------------------------------------------------------------- /test/models/test_subclass_not_in_database.rb: -------------------------------------------------------------------------------- 1 | # Please do not require this file or the class name TestSubclassNotInDatabase 2 | # anywhere. The whole point of it is that it is a subclass of TestBaseclass 3 | # which exists, but is never loaded, because it isn't mentioned anywhere. 4 | # FindSubclassesTest#test_known_subclasses tests this. 5 | class TestSubclassNotInDatabase < TestBaseclass 6 | end 7 | -------------------------------------------------------------------------------- /test/price_test.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), 'test_helper.rb') 2 | 3 | class PriceTest < Test::Unit::TestCase 4 | 5 | def test_should_be_true 6 | assert_equal(1,1) 7 | end 8 | 9 | end -------------------------------------------------------------------------------- /test/ref-output/creditnote3.html: -------------------------------------------------------------------------------- 1 |

Credit Note

2 | 3 | 4 | 5 | 6 | 7 | 8 | 19 | 29 | 30 | 31 | 34 | 37 | 38 |
RecipientSender
9 |
Lovely Customer Inc.
10 |
Fred
11 |
12 | The pasture
13 | Mootown
14 | Cow Kingdom
15 | MOOO
16 | Scotland 17 |
18 |
20 |
Unlimited Limited
21 |
Mr B. Badger
22 |
23 | The Sett
5 Badger Lane

24 | Badgertown
25 | Badger999
26 | England 27 |
28 |
32 | VAT number:
987654321 33 |
35 | VAT number:
123456789 36 |
39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 |

MyCreditNote 3

58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 |
Tax pointQuantityDescriptionNet priceVAT
2008-07-130.5moo£50.00£7.50
SubtotalNet: £50.00VAT: £7.50
Total£57.50
83 | -------------------------------------------------------------------------------- /test/ref-output/creditnote3.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | putain! 4 | 671a05d0-d1ba-012b-48a5-0017f22d32c0 5 | 2008-07-13 6 | 00:00:00+00:00 7 | 2008-07-13 8 | MyCreditNote 3 9 | 10 | 2008-06-01 11 | 00:00:00+00:00 12 | 2008-07-01 13 | 00:00:00+00:00 14 | 15 | 16 | 17 | 18 | Unlimited Limited 19 | 20 | 21 | The Sett 22 | 5 Badger Lane 23 | Badgertown 24 | Badger999 25 | 26 | 27 | GB 28 | England 29 | 30 | 31 | 32 | 123456789 33 | 34 | VAT 35 | 36 | 37 | 38 | Mr B. Badger 39 | 40 | 41 | 42 | 43 | 2 44 | 45 | 46 | Lovely Customer Inc. 47 | 48 | 49 | The pasture 50 | Mootown 51 | MOOO 52 | Cow Kingdom 53 | 54 | GB 55 | Scotland 56 | 57 | 58 | 59 | 987654321 60 | 61 | VAT 62 | 63 | 64 | 65 | Fred 66 | 67 | 68 | 69 | 70 | 7.5 71 | 72 | 73 | 50.0 74 | 57.5 75 | 76 | 77 | 5 78 | eab28cf0-d1b4-012b-48a5-0017f22d32c0 79 | 0.5 80 | 50.0 81 | 2008-07-13 82 | 83 | 7.5 84 | 85 | 86 | moo 87 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /test/ref-output/invoice1.html: -------------------------------------------------------------------------------- 1 |

Invoice

2 | 3 | 4 | 5 | 6 | 7 | 8 | 19 | 29 | 30 | 31 | 34 | 37 | 38 |
RecipientSender
9 |
Lovely Customer Inc.
10 |
Fred
11 |
12 | The pasture
13 | Mootown
14 | Cow Kingdom
15 | MOOO
16 | Scotland 17 |
18 |
20 |
Unlimited Limited
21 |
Mr B. Badger
22 |
23 | The Sett
5 Badger Lane

24 | Badgertown
25 | Badger999
26 | England 27 |
28 |
32 | VAT number:
987654321 33 |
35 | VAT number:
123456789 36 |
39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 |

MyInvoice 1

62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 |
Tax pointQuantityDescriptionNet priceVAT
2008-06-254.0this is the SubLineItem£200.00£0.00
2008-06-301.0moo£100.00£15.00
SubtotalNet: £300.00VAT: £15.00
Total£315.00
94 | -------------------------------------------------------------------------------- /test/ref-output/invoice1.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 1 4 | 30f4f680-d1b9-012b-48a5-0017f22d32c0 5 | 2008-06-30 6 | 00:00:00+00:00 7 | MyInvoice 8 | MyInvoice 1 9 | 2008-06-30 10 | 11 | 2008-06-01 12 | 00:00:00+00:00 13 | 2008-07-01 14 | 00:00:00+00:00 15 | 16 | 17 | 18 | 19 | Unlimited Limited 20 | 21 | 22 | The Sett 23 | 5 Badger Lane 24 | Badgertown 25 | Badger999 26 | 27 | 28 | GB 29 | England 30 | 31 | 32 | 33 | 123456789 34 | 35 | VAT 36 | 37 | 38 | 39 | Mr B. Badger 40 | 41 | 42 | 43 | 44 | 2 45 | 46 | 47 | Lovely Customer Inc. 48 | 49 | 50 | The pasture 51 | Mootown 52 | MOOO 53 | Cow Kingdom 54 | 55 | GB 56 | Scotland 57 | 58 | 59 | 60 | 987654321 61 | 62 | VAT 63 | 64 | 65 | 66 | Fred 67 | 68 | 69 | 70 | 71 | 72 | 2008-06-30 73 | 00:00:00+00:00 74 | 2008-07-30 75 | 00:00:00+00:00 76 | 77 | 78 | 79 | 15.0 80 | 81 | 82 | 300.0 83 | 315.0 84 | 85 | 86 | 2 87 | 0cc65e20-cfac-012b-481d-0017f22d32c0 88 | 4.0 89 | 200.0 90 | 2008-06-25 91 | 92 | 0.0 93 | 94 | 95 | this is the SubLineItem 96 | 97 | 98 | 99 | 1 100 | 0cc659f0-cfac-012b-481d-0017f22d32c0 101 | 1.0 102 | 100.0 103 | 2008-06-30 104 | 105 | 15.0 106 | 107 | 108 | moo 109 | 110 | 111 | 112 | -------------------------------------------------------------------------------- /test/ref-output/invoice2.html: -------------------------------------------------------------------------------- 1 |

Invoice

2 | 3 | 4 | 5 | 6 | 7 | 8 | 18 | 29 | 30 | 31 | 34 | 37 | 38 |
RecipientSender
9 |
Unlimited Limited
10 |
Mr B. Badger
11 |
12 | The Sett
5 Badger Lane

13 | Badgertown
14 | Badger999
15 | England 16 |
17 |
19 |
Lovely Customer Inc.
20 |
Fred
21 |
22 | The pasture
23 | Mootown
24 | Cow Kingdom
25 | MOOO
26 | Scotland 27 |
28 |
32 | VAT number:
123456789 33 |
35 | VAT number:
987654321 36 |
39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 |

InvoiceSubtype 2

62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 |
Tax pointQuantityDescriptionNet priceVAT
2009-01-011.0moo£123.45£18.52
SubtotalNet: £123.45VAT: £18.52
Total£141.97
87 | -------------------------------------------------------------------------------- /test/ref-output/invoice2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12-ASDF 4 | fe4d20a0-d1b9-012b-48a5-0017f22d32c0 5 | 2009-01-01 6 | 00:00:00+00:00 7 | 2009-01-01 8 | InvoiceSubtype 9 | InvoiceSubtype 2 10 | 11 | 2008-01-01 12 | 00:00:00+00:00 13 | 2009-01-01 14 | 00:00:00+00:00 15 | 16 | 17 | 18 | 19 | Unlimited Limited 20 | 21 | 22 | The Sett 23 | 5 Badger Lane 24 | Badgertown 25 | Badger999 26 | 27 | 28 | GB 29 | England 30 | 31 | 32 | 33 | 123456789 34 | 35 | VAT 36 | 37 | 38 | 39 | Mr B. Badger 40 | 41 | 42 | 43 | 44 | 2 45 | 46 | 47 | Lovely Customer Inc. 48 | 49 | 50 | The pasture 51 | Mootown 52 | MOOO 53 | Cow Kingdom 54 | 55 | GB 56 | Scotland 57 | 58 | 59 | 60 | 987654321 61 | 62 | VAT 63 | 64 | 65 | 66 | Fred 67 | 68 | 69 | 70 | 71 | 72 | 2009-01-01 73 | 00:00:00+00:00 74 | 2009-01-31 75 | 00:00:00+00:00 76 | 77 | 78 | 79 | 18.52 80 | 81 | 82 | 123.45 83 | 141.97 84 | 85 | 86 | 3 87 | 0cc66060-cfac-012b-481d-0017f22d32c0 88 | 1.0 89 | 123.45 90 | 2009-01-01 91 | 92 | 18.52 93 | 94 | 95 | moo 96 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /test/ref-output/invoice_null.html: -------------------------------------------------------------------------------- 1 |

Invoice

2 | 3 | 4 | 5 | 6 | 7 | 8 |

foo

9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 |
Tax pointQuantityDescriptionNet priceVATGross price
moo
SubtotalNet: —VAT: —
Total
37 | -------------------------------------------------------------------------------- /test/render_html_test.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require File.join(File.dirname(__FILE__), 'test_helper.rb') 4 | 5 | class RenderHTMLTest < Test::Unit::TestCase 6 | 7 | def reference_output(filename) 8 | IO.readlines(File.join(File.dirname(__FILE__), 'ref-output', filename)).join 9 | end 10 | 11 | def test_render_default_html_invoice 12 | assert_equal reference_output('invoice1.html'), MyInvoice.find(1).render_html 13 | end 14 | 15 | def test_render_self_billed_html_invoice 16 | assert_equal reference_output('invoice2.html'), MyInvoice.find(2).render_html 17 | end 18 | 19 | def test_render_html_credit_note 20 | #File.open(File.join(File.dirname(__FILE__), 'ref-output', 'debug3.html'), 'w') do |f| 21 | # f.syswrite(MyCreditNote.find(3).render_html) 22 | #end 23 | assert_equal reference_output('creditnote3.html'), MyCreditNote.find(3).render_html 24 | end 25 | 26 | def test_render_with_custom_fragments 27 | expected = reference_output('invoice1.html').split("\n")[0..60] 28 | expected[0] = "

INVOICE

" 29 | expected[3] = " Customer" 30 | expected[4] = " Supplier" 31 | expected[40] = " INVOICE no.:" 32 | rendered = MyInvoice.find(1).render_html {|i| 33 | i.invoice_label{ "INVOICE" } 34 | i.sender_label "Supplier" 35 | i.recipient_label "Customer" 36 | i.title_tag {|param| "

#{param[:title]}

\n" } 37 | i.line_items_table {|param| ""} 38 | } 39 | assert_equal expected.join("\n") + "\n", rendered 40 | end 41 | 42 | def test_render_empty_invoice 43 | invoice = MyInvoice.new 44 | invoice.line_items2 << SuperLineItem.new 45 | invoice.save! 46 | invoice.tax_amount2 = nil 47 | invoice.total_amount2 = nil 48 | rendered = invoice.render_html({:tax_point_column => true, :quantity_column => true, 49 | :description_column => true, :net_amount_column => true, :tax_amount_column => true, 50 | :gross_amount_column => true}) {|i| i.addresses_table{|x| ""}; i.description "foo" } 51 | assert_equal reference_output('invoice_null.html'), rendered 52 | end 53 | 54 | def test_render_with_null_fragment 55 | assert_raise ArgumentError do 56 | MyInvoice.find(1).render_html do |i| 57 | i.invoice_label 58 | end 59 | end 60 | end 61 | 62 | def test_render_with_too_many_fragments 63 | assert_raise ArgumentError do 64 | MyInvoice.find(1).render_html do |i| 65 | i.invoice_label "a", "b" 66 | end 67 | end 68 | end 69 | 70 | end -------------------------------------------------------------------------------- /test/render_ubl_test.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require File.join(File.dirname(__FILE__), 'test_helper.rb') 4 | 5 | class RenderUBLTest < Test::Unit::TestCase 6 | 7 | def reference_output(filename) 8 | IO.readlines(File.join(File.dirname(__FILE__), 'ref-output', filename)).join 9 | end 10 | 11 | # Compares two strings, each being a serialised XML document, while ignoring 12 | # the order of attributes within elements. 13 | # TODO: this could generate much nicer error messages on failure. 14 | def assert_equivalent_xml(doc1, doc2) 15 | doc1, doc2 = [doc1, doc2].map do |doc| 16 | doc.gsub(/(<[^\s>]+\s+)([^>]+)(>)/) do |match| 17 | $1.to_s + $2.to_s.split(/\s+/).sort.join(' ') + $3.to_s 18 | end 19 | end 20 | assert_equal doc1, doc2 21 | end 22 | 23 | def test_render_ubl_invoice 24 | assert_equivalent_xml reference_output('invoice1.xml'), MyInvoice.find(1).render_ubl 25 | end 26 | 27 | def test_render_ubl_self_billed_invoice 28 | assert_equivalent_xml reference_output('invoice2.xml'), MyInvoice.find(2).render_ubl 29 | end 30 | 31 | def test_render_ubl_credit_note 32 | #File.open(File.join(File.dirname(__FILE__), 'ref-output', 'debug3.xml'), 'w') do |f| 33 | # f.syswrite(MyCreditNote.find(3).render_ubl) 34 | #end 35 | assert_equivalent_xml reference_output('creditnote3.xml'), MyCreditNote.find(3).render_ubl 36 | end 37 | 38 | def test_cannot_render_unknown_ledger_item_subtype 39 | assert_raise RuntimeError do 40 | CorporationTaxLiability.find(6).render_ubl 41 | end 42 | end 43 | 44 | end -------------------------------------------------------------------------------- /test/setup.rb: -------------------------------------------------------------------------------- 1 | # This file is silently executed before the entire test suite runs, when run by 'rake test'. 2 | # To see its output, set the environment variable VERBOSE=1 3 | 4 | require File.join(File.dirname(__FILE__), "test_helper.rb") 5 | 6 | connection = ActiveRecord::Base.connection 7 | 8 | Dir.glob(File.join(File.dirname(__FILE__), 'fixtures', '*.sql')) do |filename| 9 | file = File.new(File.expand_path(filename)) 10 | 11 | command = '' 12 | file.each do |line| 13 | 14 | # Hacks to make fixture loading work with postgres. Very very ugly. Sorry :-( 15 | if ActiveRecord::Base.connection.adapter_name == 'PostgreSQL' 16 | line.gsub!(/datetime/, 'timestamp') 17 | line.gsub!(/tinyint\(1\)/, 'boolean') 18 | line.gsub!(/0(\).) \-\- false/, 'false\1') 19 | line.gsub!(/1(\).) \-\- true/, 'true\1') 20 | line.gsub!(/int primary key auto_increment/, 'serial primary key') 21 | line.gsub!(/ENGINE=.*;/, ';') 22 | else 23 | line.gsub!(/ALTER SEQUENCE .*/, '') 24 | end 25 | 26 | line.gsub!(/\-\-.*/, '') # ignore comments 27 | 28 | if line =~ /(.*);\s*\Z/ # cut off semicolons at the end of a command 29 | command += ' ' + $1 30 | puts command.strip 31 | connection.execute command 32 | command = '' 33 | else 34 | command += ' ' + line.strip 35 | end 36 | end 37 | end -------------------------------------------------------------------------------- /test/tax_rate_test.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), 'test_helper.rb') 2 | 3 | class TaxRateTest < Test::Unit::TestCase 4 | 5 | def test_should_be_true 6 | assert_equal(1,1) 7 | end 8 | 9 | end -------------------------------------------------------------------------------- /test/taxable_test.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require File.join(File.dirname(__FILE__), 'test_helper.rb') 4 | 5 | class TaxableTest < Test::Unit::TestCase 6 | 7 | class SimpleTaxLogic 8 | def apply_tax(params) 9 | if params[:attribute].to_s == 'amount' 10 | params[:value] * (BigDecimal('1.0') + params[:model_object].tax_factor) 11 | else 12 | params[:value] 13 | end 14 | end 15 | 16 | def remove_tax(params) 17 | if params[:attribute].to_s == 'amount' 18 | params[:value] / (BigDecimal('1.0') + params[:model_object].tax_factor) 19 | else 20 | params[:value] 21 | end 22 | end 23 | 24 | def tax_info(params) 25 | if params[:attribute].to_s == 'amount' 26 | "(inc. tax)" 27 | else 28 | "" 29 | end 30 | end 31 | 32 | def tax_details(params) 33 | if params[:attribute].to_s == 'amount' 34 | "(including #{sprintf('%.2f', 100*params[:model_object].tax_factor)}% tax)" 35 | else 36 | "(tax not applicable)" 37 | end 38 | end 39 | 40 | def some_other_method(options, param1, param2) 41 | param1 * param2 + options[:model_object].id 42 | end 43 | 44 | def mixin_methods 45 | [:some_other_method] 46 | end 47 | end 48 | 49 | 50 | class TaxableRecord < ActiveRecord::Base 51 | validates_numericality_of :amount 52 | acts_as_taxable :amount, :gross_amount, :tax_logic => SimpleTaxLogic.new, :currency => :currency_code 53 | end 54 | 55 | class NonsenseClass < ActiveRecord::Base 56 | set_table_name 'taxable_record' 57 | end 58 | 59 | 60 | ###################################################################### 61 | 62 | def test_raises_error_if_no_tax_logic_is_specified 63 | assert_raise ArgumentError do 64 | NonsenseClass.class_eval do 65 | acts_as_taxable :amount 66 | end 67 | end 68 | end 69 | 70 | def test_apply_tax_on_existing_record 71 | record = TaxableRecord.find(1) 72 | assert_equal BigDecimal('141.09'), record.amount_taxed 73 | assert_equal BigDecimal('123.45'), record.amount 74 | end 75 | 76 | def test_apply_tax_on_new_record 77 | record = TaxableRecord.new(:amount => '200', :tax_factor => '0.4', :currency_code => 'USD') 78 | assert_equal BigDecimal('280'), record.amount_taxed 79 | assert_equal BigDecimal('200'), record.amount 80 | assert_equal '$280.00', record.amount_taxed_formatted 81 | assert_equal '$200.00', record.amount_formatted 82 | assert_equal '200', record.amount_before_type_cast 83 | record.save! 84 | assert_equal([{'amount' => '200.0000'}], 85 | ActiveRecord::Base.connection.select_all("SELECT amount FROM taxable_records WHERE id=#{record.id}")) 86 | end 87 | 88 | def test_remove_tax_on_existing_record 89 | record = TaxableRecord.find(1) 90 | record.amount_taxed = 114.29 91 | assert_equal BigDecimal('100.00'), record.amount 92 | assert_equal BigDecimal('114.29'), record.amount_taxed 93 | assert_equal '£100.00', record.amount_formatted 94 | assert_equal '£114.29', record.amount_taxed_formatted 95 | assert_equal 114.29, record.amount_taxed_before_type_cast 96 | record.save! 97 | assert_equal([{'amount' => '100.0000'}], 98 | ActiveRecord::Base.connection.select_all("SELECT amount FROM taxable_records WHERE id=1")) 99 | end 100 | 101 | def test_remove_tax_on_new_record 102 | record = TaxableRecord.new(:amount_taxed => '360', :tax_factor => '0.2', :currency_code => 'USD') 103 | assert_equal BigDecimal('300'), record.amount 104 | assert_equal BigDecimal('360'), record.amount_taxed 105 | assert_equal '$300.00', record.amount_formatted 106 | assert_equal '$360.00', record.amount_taxed_formatted 107 | assert_equal '360', record.amount_taxed_before_type_cast 108 | record.save! 109 | assert_equal([{'amount' => '300.0000'}], 110 | ActiveRecord::Base.connection.select_all("SELECT amount FROM taxable_records WHERE id=#{record.id}")) 111 | end 112 | 113 | def test_assign_taxed_then_untaxed 114 | record = TaxableRecord.find(1) 115 | record.amount_taxed = '333.33' 116 | record.amount = '1210.11' 117 | assert_equal BigDecimal('1382.98'), record.amount_taxed 118 | assert_equal BigDecimal('1210.11'), record.amount 119 | assert_equal '1210.11', record.amount_before_type_cast 120 | end 121 | 122 | def test_assign_untaxed_then_taxed 123 | record = TaxableRecord.find(1) 124 | record.amount = '0.02' 125 | record.amount_taxed = '1142.86' 126 | assert_equal BigDecimal('1000.00'), record.amount 127 | assert_equal BigDecimal('1142.86'), record.amount_taxed 128 | assert_equal '1142.86', record.amount_taxed_before_type_cast 129 | end 130 | 131 | def test_no_rounding_error 132 | record = TaxableRecord.new(:amount_taxed => 100, :tax_factor => 1.0/3.0) 133 | assert_equal BigDecimal('0'), record.amount_tax_rounding_error 134 | assert_equal BigDecimal('100'), record.amount_taxed 135 | assert_equal BigDecimal('75'), record.amount 136 | end 137 | 138 | def test_rounding_error_high 139 | record = TaxableRecord.new(:amount_taxed => 1.04, :tax_factor => 0.175) 140 | assert_equal BigDecimal('0.01'), record.amount_tax_rounding_error 141 | assert_equal BigDecimal('0.89'), record.amount 142 | assert_equal BigDecimal('1.05'), record.amount_taxed 143 | end 144 | 145 | def test_rounding_error_low 146 | record = TaxableRecord.new(:amount_taxed => 1.11, :tax_factor => 0.175) 147 | assert_equal BigDecimal('1.10'), record.amount_taxed 148 | assert_equal BigDecimal('0.94'), record.amount 149 | assert_equal BigDecimal('-0.01'), record.amount_tax_rounding_error 150 | end 151 | 152 | def test_tax_info 153 | record = TaxableRecord.find(1) 154 | assert_equal "(inc. tax)", record.amount_tax_info 155 | assert_equal "", record.gross_amount_tax_info 156 | end 157 | 158 | def test_tax_details 159 | record = TaxableRecord.find(1) 160 | assert_equal "(including 14.29% tax)", record.amount_tax_details 161 | assert_equal "(tax not applicable)", record.gross_amount_tax_details 162 | end 163 | 164 | def test_with_tax_info 165 | record = TaxableRecord.find(1) 166 | assert_equal "£141.09 (inc. tax)", record.amount_with_tax_info 167 | assert_equal "£141.09", record.gross_amount_with_tax_info 168 | end 169 | 170 | def test_with_tax_details 171 | record = TaxableRecord.find(1) 172 | assert_equal "£141.09 (including 14.29% tax)", record.amount_with_tax_details 173 | assert_equal "£141.09 (tax not applicable)", record.gross_amount_with_tax_details 174 | end 175 | 176 | def test_other_method 177 | record = TaxableRecord.find(1) 178 | assert_equal 49, record.some_other_method(6, 8) 179 | end 180 | end 181 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'test/unit' 2 | require 'rubygems' 3 | require 'active_record' 4 | require 'active_support' 5 | require 'flexmock/test_unit' 6 | 7 | $: << File.join(File.dirname(__FILE__), '..', 'lib') 8 | 9 | ActiveSupport::Dependencies.load_paths << File.join(File.dirname(__FILE__), 'models') 10 | 11 | require 'invoicing' 12 | 13 | # Overridden by ../../config/database.yml if it exists. 14 | TEST_DB_CONFIG = { 15 | :postgresql => {:adapter => "postgresql", :host => "localhost", :database => "invoicing_test", 16 | :username => "invoicing", :password => "password"}, 17 | :mysql => {:adapter => "mysql", :host => "localhost", :database => "invoicing_test", 18 | :username => "root", :password => ""} 19 | } 20 | TEST_DB_CONFIG_FILE = File.expand_path(File.join(File.dirname(__FILE__), '..', 'config', 'database.yml')) 21 | 22 | def database_used_for_testing 23 | (ENV['DATABASE'] || :mysql).to_sym 24 | end 25 | 26 | def test_in_all_databases 27 | !!ENV['TEST_ALL_DATABASES'] 28 | end 29 | 30 | def connect_to_testing_database 31 | db_config = TEST_DB_CONFIG[database_used_for_testing] 32 | db_config_from_file = false 33 | 34 | if File.exists? TEST_DB_CONFIG_FILE 35 | yaml = YAML::load File.open(TEST_DB_CONFIG_FILE) 36 | if yaml && yaml['test'] && (yaml['test']['adapter'].to_s == database_used_for_testing.to_s) 37 | db_config = yaml['test'] 38 | db_config_from_file = true 39 | end 40 | end 41 | 42 | puts "Connecting to #{database_used_for_testing} with config #{db_config.inspect}" + 43 | (db_config_from_file ? " from #{TEST_DB_CONFIG_FILE}" : "") 44 | ActiveRecord::Base.establish_connection(db_config) 45 | end 46 | 47 | connect_to_testing_database 48 | 49 | require File.join(File.dirname(__FILE__), 'setup') 50 | 51 | 52 | ENV['TZ'] = 'Etc/UTC' # timezone of values in database 53 | ActiveRecord::Base.default_timezone = :utc # timezone of created_at and updated_at 54 | Time.zone = 'Etc/UTC' # timezone for output (when using Time#in_time_zone) 55 | 56 | 57 | # Behave a bit like ActiveRecord's transactional fixtures. 58 | module Test 59 | module Unit 60 | class TestCase 61 | def setup 62 | ActiveRecord::Base.connection.increment_open_transactions 63 | ActiveRecord::Base.connection.begin_db_transaction 64 | end 65 | 66 | def teardown 67 | ActiveRecord::Base.connection.rollback_db_transaction 68 | ActiveRecord::Base.connection.decrement_open_transactions 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /test/time_dependent_test.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), 'test_helper.rb') 2 | 3 | class CachedRecordTest < Test::Unit::TestCase 4 | 5 | class TimeDependentRecord < ActiveRecord::Base 6 | # All columns are renamed to test renaming 7 | set_primary_key 'id2' 8 | acts_as_time_dependent :id => 'id2', :valid_from => 'valid_from2', :valid_until => 'valid_until2', 9 | :replaced_by_id => 'replaced_by_id2', :value => 'value2', :is_default => 'is_default2' 10 | end 11 | 12 | 13 | def test_valid_records_during_single_period 14 | records = TimeDependentRecord.valid_records_during(DateTime.parse('2009-01-01'), DateTime.parse('2009-03-01')) 15 | assert_equal [3, 6, 8, 10], records.map{|r| r.id2}.sort 16 | end 17 | 18 | def test_valid_records_during_single_period_ending_on_change_date 19 | records = TimeDependentRecord.valid_records_during(DateTime.parse('2008-10-31'), DateTime.parse('2009-01-01')) 20 | assert_equal [1, 2, 5, 8, 10], records.map{|r| r.id2}.sort 21 | end 22 | 23 | def test_valid_records_during_transition_period 24 | records = TimeDependentRecord.valid_records_during(DateTime.parse('2008-09-01'), DateTime.parse('2009-02-28')) 25 | assert_equal [1, 2, 5, 6, 8, 10], records.map{|r| r.id2}.sort 26 | end 27 | 28 | def test_valid_records_during_period_after_unreplaced_expiry 29 | records = TimeDependentRecord.valid_records_during(DateTime.parse('2011-09-01'), DateTime.parse('2011-09-02')) 30 | assert_equal [4, 9, 10], records.map{|r| r.id2}.sort 31 | end 32 | 33 | def test_valid_records_at_boundary 34 | records = TimeDependentRecord.valid_records_at(DateTime.parse('2010-01-01')) 35 | assert_equal [4, 7, 8, 10], records.map{|r| r.id2}.sort 36 | end 37 | 38 | def test_valid_records_at_middle_of_period 39 | records = TimeDependentRecord.valid_records_at(DateTime.parse('2009-07-01')) 40 | assert_equal [3, 6, 8, 10], records.map{|r| r.id2}.sort 41 | end 42 | 43 | def test_valid_records_at_just_before_end_of_period 44 | records = TimeDependentRecord.valid_records_at(DateTime.parse('2008-12-31 23:59:59')) 45 | assert_equal [1, 2, 5, 8, 10], records.map{|r| r.id2}.sort 46 | end 47 | 48 | def test_default_record_at_returns_default 49 | assert_equal 9, TimeDependentRecord.default_record_at(DateTime.parse('2011-04-01')).id2 50 | end 51 | 52 | def test_default_record_at_where_there_is_no_default 53 | assert_nil TimeDependentRecord.default_record_at(DateTime.parse('2008-03-01')) 54 | end 55 | 56 | def test_default_value_at 57 | assert_equal 'Seven', TimeDependentRecord.default_value_at(DateTime.parse('2010-01-01 00:00:01')) 58 | end 59 | 60 | def test_default_value_at_alias 61 | assert_equal 'Six', TimeDependentRecord.default_value2_at(DateTime.parse('2009-12-31 23:59:59')) 62 | end 63 | 64 | def test_default_record_now 65 | # Hello future. This is January 2009 speaking. Is someone out there still using this library? 66 | # If so, you may want to update this test from time to time. But you probably won't need to. 67 | expected = case Date.today.year 68 | when 2009 then 6 69 | when 2010 then 7 70 | else 9 71 | end 72 | assert_equal expected, TimeDependentRecord.default_record_now.id2 73 | end 74 | 75 | def test_default_value_now 76 | expected = case Date.today.year 77 | when 2009 then 'Six' 78 | when 2010 then 'Seven' 79 | else 'Nine' 80 | end 81 | assert_equal expected, TimeDependentRecord.default_value_now 82 | end 83 | 84 | def test_default_value_now_alias 85 | expected = case Date.today.year 86 | when 2009 then 'Six' 87 | when 2010 then 'Seven' 88 | else 'Nine' 89 | end 90 | assert_equal expected, TimeDependentRecord.default_value2_now 91 | end 92 | 93 | def test_multiple_predecessors 94 | assert_equal [2, 5], TimeDependentRecord.find(3).predecessors.map{|r| r.id2}.sort 95 | end 96 | 97 | def test_one_predecessor 98 | assert_equal [8], TimeDependentRecord.find(9).predecessors.map{|r| r.id2} 99 | end 100 | 101 | def test_no_predecessors 102 | assert_equal [], TimeDependentRecord.find(1).predecessors 103 | end 104 | 105 | def test_record_at_same_period 106 | assert_equal 3, TimeDependentRecord.find(3).record_at(DateTime.parse('2009-12-31 23:59:59')).id2 107 | end 108 | 109 | def test_record_at_next_period 110 | assert_equal 4, TimeDependentRecord.find(3).record_at(DateTime.parse('2010-01-01 00:00:00')).id2 111 | end 112 | 113 | def test_record_at_future_period 114 | assert_equal 4, TimeDependentRecord.find(2).record_at(DateTime.parse('2036-07-09')).id2 115 | end 116 | 117 | def test_record_at_within_long_period 118 | assert_equal 8, TimeDependentRecord.find(8).record_at(DateTime.parse('2010-12-31 23:59:58')).id2 119 | end 120 | 121 | def test_record_at_with_no_replacement 122 | assert_nil TimeDependentRecord.find(1).record_at(DateTime.parse('2009-07-09')) 123 | end 124 | 125 | def test_record_at_with_no_predecessor 126 | assert_nil TimeDependentRecord.find(7).record_at(DateTime.parse('2008-07-09')) 127 | end 128 | 129 | def test_record_at_with_unique_predecessor 130 | assert_equal 3, TimeDependentRecord.find(4).record_at(DateTime.parse('2009-01-01')).id2 131 | end 132 | 133 | def test_record_at_with_ambiguous_predecessor 134 | assert_nil TimeDependentRecord.find(4).record_at(DateTime.parse('2008-12-31')) 135 | end 136 | 137 | def test_record_at_long_ago 138 | assert_nil TimeDependentRecord.find(10).record_at(DateTime.parse('1970-01-01')) 139 | end 140 | 141 | def test_record_now 142 | assert_equal 10, TimeDependentRecord.find(10).record_now.id2 143 | end 144 | 145 | def test_value_at 146 | assert_equal 'Four', TimeDependentRecord.find(5).value_at(DateTime.parse('2028-01-13')) 147 | end 148 | 149 | def test_value_at_alias 150 | assert_equal 'Four', TimeDependentRecord.find(5).value2_at(DateTime.parse('2028-01-13')) 151 | end 152 | 153 | def test_value_now 154 | assert_equal 'Ten', TimeDependentRecord.find(10).value_now 155 | end 156 | 157 | def test_value_now_alias 158 | assert_equal 'Ten', TimeDependentRecord.find(10).value2_now 159 | end 160 | 161 | def test_changes_until_without_changes 162 | assert_equal [], TimeDependentRecord.find(8).changes_until(DateTime.parse('2010-12-31 23:59:59')) 163 | end 164 | 165 | def test_changes_until_with_one_change 166 | assert_equal [9], TimeDependentRecord.find(8).changes_until(DateTime.parse('2011-01-01')).map{|r| r.id2} 167 | end 168 | 169 | def test_changes_until_with_multiple_changes 170 | assert_equal [3, 4], TimeDependentRecord.find(2).changes_until(DateTime.parse('2034-01-01')).map{|r| r.id2} 171 | end 172 | 173 | def test_changes_until_with_imminent_expiry 174 | assert_equal [nil], TimeDependentRecord.find(1).changes_until(DateTime.parse('2009-01-01')) 175 | end 176 | 177 | def test_changes_until_with_future_expiry 178 | assert_equal [TimeDependentRecord.find(7), nil], TimeDependentRecord.find(6).changes_until(DateTime.parse('2012-01-01')) 179 | end 180 | end 181 | --------------------------------------------------------------------------------