invoicing 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (62) hide show
  1. data/CHANGELOG +3 -0
  2. data/LICENSE +20 -0
  3. data/Manifest +60 -0
  4. data/README +48 -0
  5. data/Rakefile +75 -0
  6. data/invoicing.gemspec +41 -0
  7. data/lib/invoicing.rb +9 -0
  8. data/lib/invoicing/cached_record.rb +107 -0
  9. data/lib/invoicing/class_info.rb +187 -0
  10. data/lib/invoicing/connection_adapter_ext.rb +44 -0
  11. data/lib/invoicing/countries/uk.rb +24 -0
  12. data/lib/invoicing/currency_value.rb +212 -0
  13. data/lib/invoicing/find_subclasses.rb +193 -0
  14. data/lib/invoicing/ledger_item.rb +718 -0
  15. data/lib/invoicing/ledger_item/render_html.rb +515 -0
  16. data/lib/invoicing/ledger_item/render_ubl.rb +268 -0
  17. data/lib/invoicing/line_item.rb +246 -0
  18. data/lib/invoicing/price.rb +9 -0
  19. data/lib/invoicing/tax_rate.rb +9 -0
  20. data/lib/invoicing/taxable.rb +355 -0
  21. data/lib/invoicing/time_dependent.rb +388 -0
  22. data/lib/invoicing/version.rb +21 -0
  23. data/test/cached_record_test.rb +100 -0
  24. data/test/class_info_test.rb +253 -0
  25. data/test/connection_adapter_ext_test.rb +71 -0
  26. data/test/currency_value_test.rb +184 -0
  27. data/test/find_subclasses_test.rb +120 -0
  28. data/test/fixtures/README +7 -0
  29. data/test/fixtures/cached_record.sql +22 -0
  30. data/test/fixtures/class_info.sql +28 -0
  31. data/test/fixtures/currency_value.sql +29 -0
  32. data/test/fixtures/find_subclasses.sql +43 -0
  33. data/test/fixtures/ledger_item.sql +39 -0
  34. data/test/fixtures/line_item.sql +33 -0
  35. data/test/fixtures/price.sql +4 -0
  36. data/test/fixtures/tax_rate.sql +4 -0
  37. data/test/fixtures/taxable.sql +14 -0
  38. data/test/fixtures/time_dependent.sql +35 -0
  39. data/test/ledger_item_test.rb +352 -0
  40. data/test/line_item_test.rb +139 -0
  41. data/test/models/README +4 -0
  42. data/test/models/test_subclass_in_another_file.rb +3 -0
  43. data/test/models/test_subclass_not_in_database.rb +6 -0
  44. data/test/price_test.rb +9 -0
  45. data/test/ref-output/creditnote3.html +82 -0
  46. data/test/ref-output/creditnote3.xml +89 -0
  47. data/test/ref-output/invoice1.html +93 -0
  48. data/test/ref-output/invoice1.xml +111 -0
  49. data/test/ref-output/invoice2.html +86 -0
  50. data/test/ref-output/invoice2.xml +98 -0
  51. data/test/ref-output/invoice_null.html +36 -0
  52. data/test/render_html_test.rb +69 -0
  53. data/test/render_ubl_test.rb +32 -0
  54. data/test/setup.rb +37 -0
  55. data/test/tax_rate_test.rb +9 -0
  56. data/test/taxable_test.rb +180 -0
  57. data/test/test_helper.rb +48 -0
  58. data/test/time_dependent_test.rb +180 -0
  59. data/website/curvycorners.js +1 -0
  60. data/website/screen.css +149 -0
  61. data/website/template.html.erb +43 -0
  62. metadata +180 -0
@@ -0,0 +1,9 @@
1
+ module Invoicing
2
+ module Price
3
+ module ActMethods
4
+ def acts_as_price(*args)
5
+
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module Invoicing
2
+ module TaxRate
3
+ module ActMethods
4
+ def acts_as_tax_rate(*args)
5
+
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,355 @@
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 <tt>ActiveRecord::Base</tt>) 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 <b>must always
27
+ # store values excluding tax</b>. The option <tt>:tax_logic</tt> 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 <tt>:currency => '...'</tt> option.
35
+ #
36
+ # For each attribute which you declare as taxable, several new methods are generated on your model class:
37
+ #
38
+ # <tt><attr></tt>:: Returns the amount of money excluding tax, as stored in the database,
39
+ # subject to the model object's currency rounding conventions.
40
+ # <tt><attr>=</tt>:: Assigns a new value (exclusive of tax) to the attribute.
41
+ # <tt><attr>_taxed</tt>:: Returns the amount of money including tax, as computed by the tax
42
+ # logic, subject to the model object's currency rounding conventions.
43
+ # <tt><attr>_taxed=</tt>:: Assigns a new value (including tax) to the attribute.
44
+ # <tt><attr>_tax_rounding_error</tt>:: 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
+ # <tt><attr>_tax_info</tt>:: Returns a short string to inform a user about the tax status of
49
+ # the value returned by <tt><attr>_taxed</tt>; this could be
50
+ # "inc. VAT", for example, if the +_taxed+ attribute includes VAT.
51
+ # <tt><attr>_tax_details</tt>:: 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
+ # <tt><attr>_with_tax_info</tt>:: 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
+ # <tt><attr>_with_tax_details</tt>:: Like +_with_tax_info+, but using +_tax_details+. For example,
59
+ # "AU$1,234.00 including 10% Goods and Services Tax".
60
+ # <tt><attr>_taxed_before_type_cast</tt>:: Returns any value which you assign to <tt><attr>_taxed=</tt> 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 <tt><attr>_taxed</tt> 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
+ # <tt><attr>_formatted</tt> and <tt><attr>_taxed_formatted</tt>, 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 <b>always use the +_taxed+ attributes in views and forms</b>, unless you have a
86
+ # very good reason not to. The value returned by <tt><attr>_taxed</tt>, and the value you assign to
87
+ # <tt><attr>_taxed=</tt>, 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
+ # <h1>Products</h1>
99
+ # <table>
100
+ # <tr>
101
+ # <th>Name</th>
102
+ # <th>Price</th>
103
+ # </tr>
104
+ # <% for product in @products %>
105
+ # <tr>
106
+ # <td><%=h product.name %></td>
107
+ # <td><%=h product.price_with_tax_info %></td> # e.g. "$25.80 (inc. tax)"
108
+ # </tr>
109
+ # <% end %>
110
+ # </table>
111
+ #
112
+ # <h1>New product</h1>
113
+ # <% form_for(@product) do |f| %>
114
+ # <%= f.error_messages %>
115
+ # <p>
116
+ # <%= f.label :name, "Product name:" %><br />
117
+ # <%= f.text_field :name %>
118
+ # </p>
119
+ # <p>
120
+ # <%= f.label :price_taxed, "Price #{h(@product.price_tax_info)}:" %><br /> # e.g. "Price (inc. tax):"
121
+ # <%= f.text_field :price_taxed %>
122
+ # </p>
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.<attr>_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 <tt><attr>_taxed=</tt> attribute, the <tt><attr>_tax_rounding_error</tt>
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
+ # <tt>:tax_logic</tt>:: Object with instance methods apply_tax, remove_tax, tax_info and tax_details
212
+ # as documented in the +Taxable+ module. Required.
213
+ # <tt>:currency</tt>:: 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 <tt>:taxed</tt> or <tt>:untaxed</tt>.
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
@@ -0,0 +1,388 @@
1
+ module Invoicing
2
+ # == Time-dependent value objects
3
+ #
4
+ # This module implements the notion of a value (or a set of values) which may change at
5
+ # certain points in time, and for which it is important to have a full history of values
6
+ # for every point in time. It is used in the invoicing framework as basis for tax rates,
7
+ # prices, commissions etc.
8
+ #
9
+ # === Background
10
+ #
11
+ # To illustrate the need for this tool, consider for example the case of a tax rate. Say
12
+ # the rate is currently 10%, and in a naive implementation you simply store the value
13
+ # <tt>0.1</tt> in a constant. Whenever you need to calculate tax on a price, you multiply
14
+ # the price with the constant, and store the result together with the price in the database.
15
+ # Then, one day the government decides to increase the tax rate to 12%. On the day the
16
+ # change takes effect, you change the value of the constant to <tt>0.12</tt>.
17
+ #
18
+ # This naive implementation has a number of problems, which are addressed by this module:
19
+ # * With a constant, you have no way of informing users what a price will be after an
20
+ # upcoming tax change. Using +TimeDependent+ allows you to query the value on any date
21
+ # in the past or future, and show it to users as appropriate. You also gain the ability
22
+ # to process back-dated or future-dated transactions if this should be necessary.
23
+ # * With a constant, you have no explicit information in your database informing you which
24
+ # rate was applied for a particular tax calculation. You may be able to infer the rate
25
+ # from the prices you store, but this may not be enough in cases where there is additional
26
+ # metadata attached to tax rates (e.g. if there are different tax rates for different
27
+ # types of product). With +TimeDependent+ you can have an explicit reference to the tax
28
+ # object which formed the basis of a calculation, giving you a much better audit trail.
29
+ # * If there are different tax categories (e.g. a reduced rate for products of type A, and
30
+ # a higher rate for type B), the government may not only change the rates themselves, but
31
+ # also decide to reclassify product X as type B rather than type A. In any case you will
32
+ # need to store the type of each of your products; however, +TimeDependent+ tries to
33
+ # minimize the amount of reclassifying you need to do, should it become necessary.
34
+ #
35
+ # == Data Structure
36
+ #
37
+ # +TimeDependent+ objects are special ActiveRecord::Base objects. One database table is used,
38
+ # and each row in that table represents the value (e.g. the tax rate or the price) during
39
+ # a particular period of time. If there are multiple different values at the same time (e.g.
40
+ # a reduced tax rate and a higher rate), each of these is also represented as a separate
41
+ # row. That way you can refer to a +TimeDependent+ object from another model object (such as
42
+ # storing the tax category for a product), and refer simultaneously to the type of tax
43
+ # applicable for this product and the period for which this classification is valid.
44
+ #
45
+ # If a rate change is announced, it <b>important that the actual values in the table
46
+ # are not changed</b> in order to preserve historical information. Instead, add another
47
+ # row (or several rows), taking effect on the appropriate date. However, it is usually
48
+ # not necessary to update your other model objects to refer to these new rows; instead,
49
+ # each +TimeDependent+ object which expires has a reference to the new +TimeDependent+
50
+ # objects which replaces it. +TimeDependent+ provides methods for finding the current (or
51
+ # future) rate by following this chain of replacements.
52
+ #
53
+ # === Example
54
+ #
55
+ # To illustrate, take as example the rate of VAT (Value Added Tax) in the United Kingdom.
56
+ # The main tax rate was at 17.5% until 1 December 2008, when it was changed to 15%.
57
+ # On 1 January 2010 it is due to be changed back to 17.5%. At the same time, there are a
58
+ # reduced rates of 5% and 0% on certain goods; while the main rate was changed, the
59
+ # reduced rates stayed unchanged.
60
+ #
61
+ # The table of +TimeDependent+ records will look something like this:
62
+ #
63
+ # +----+-------+---------------+------------+---------------------+---------------------+----------------+
64
+ # | id | value | description | is_default | valid_from | valid_until | replaced_by_id |
65
+ # +----+-------+---------------+------------+---------------------+---------------------+----------------+
66
+ # | 1 | 0.175 | Standard rate | 1 | 1991-04-01 00:00:00 | 2008-12-01 00:00:00 | 4 |
67
+ # | 2 | 0.05 | Reduced rate | 0 | 1991-04-01 00:00:00 | NULL | NULL |
68
+ # | 3 | 0.0 | Zero rate | 0 | 1991-04-01 00:00:00 | NULL | NULL |
69
+ # | 4 | 0.15 | Standard rate | 1 | 2008-12-01 00:00:00 | 2010-01-01 00:00:00 | 5 |
70
+ # | 5 | 0.175 | Standard rate | 1 | 2010-01-01 00:00:00 | NULL | NULL |
71
+ # +----+-------+---------------+------------+---------------------+---------------------+----------------+
72
+ #
73
+ # Graphically, this may be illustrated as:
74
+ #
75
+ # 1991-04-01 2008-12-01 2010-01-01
76
+ # : : :
77
+ # Standard rate: 17.5% -----------------> 15% ---------------> 17.5% ----------------->
78
+ # : : :
79
+ # Zero rate: 0% --------------------------------------------------------------->
80
+ # : : :
81
+ # Reduced rate: 5% --------------------------------------------------------------->
82
+ #
83
+ # It is a deliberate choice that a +TimeDependent+ object references its successor, and not
84
+ # its predecessor. This is so that you can classify your items based on the current
85
+ # classification, and be sure that if the current rate expires there is an unambiguous
86
+ # replacement for it. On the other hand, it is usually not important to know what the rate
87
+ # for a particular item would have been at some point in the past.
88
+ #
89
+ # Now consider a slightly more complicated (fictional) example, in which a UK court rules
90
+ # that teacakes have been incorrectly classified for VAT purposes, namely that they should
91
+ # have been zero-rated while actually they had been standard-rated. The court also decides
92
+ # that all sales of teacakes before 1 Dec 2008 should maintain their old standard-rated status,
93
+ # while sales from 1 Dec 2008 onwards should be zero-rated.
94
+ #
95
+ # Assume you have an online shop in which you sell teacakes and other goods (both standard-rated
96
+ # and zero-rated). You can handle this reclassification (in addition to the standard VAT rate
97
+ # change above) as follows:
98
+ #
99
+ # 1991-04-01 2008-12-01 2010-01-01
100
+ # : : :
101
+ # Standard rate: 17.5% -----------------> 15% ---------------> 17.5% ----------------->
102
+ # : : :
103
+ # Teacakes: 17.5% ------------. : :
104
+ # : \_ : :
105
+ # Zero rate: 0% ---------------+-> 0% ---------------------------------------->
106
+ # : : :
107
+ # Reduced rate: 5% --------------------------------------------------------------->
108
+ #
109
+ # Then you just need to update the teacake products in your database, which previously referred
110
+ # to the 17.5% object valid from 1991-04-01, to refer to the special teacake rate. None of the
111
+ # other products need to be modified. This way, the teacakes will automatically switch to the 0%
112
+ # rate on 2008-12-01. If you add any new teacake products to the database after December 2008, you
113
+ # can refer either to the teacake rate or to the new 0% rate which takes effect on 2008-12-01;
114
+ # it won't make any difference.
115
+ #
116
+ # == Usage notes
117
+ #
118
+ # This implementation is designed for tables with a small number of rows (no more than a few
119
+ # dozen) and very infrequent changes. To reduce database load, it caches model objects very
120
+ # aggressively; <b>you will need to restart your Ruby interpreter after making a change to
121
+ # the data</b> as the cache is not cleared between requests. This is ok because you shouldn't
122
+ # be lightheartedly modifying +TimeDependent+ data anyway; a database migration as part of an
123
+ # explicitly deployed release is probably the best way of introducing a rate change
124
+ # (that way you can also check it all looks correct on your staging server before making the
125
+ # rate change public).
126
+ #
127
+ # A model object using +TimeDependent+ must inherit from ActiveRecord::Base and must have
128
+ # at least the following columns (although columns may have different names, if declared to
129
+ # +acts_as_time_dependent+):
130
+ # * <tt>id</tt> -- An integer primary key
131
+ # * <tt>valid_from</tt> -- A column of type <tt>datetime</tt>, which must not be <tt>NULL</tt>.
132
+ # It contains the moment at which the rate takes effect. The oldest <tt>valid_from</tt> dates
133
+ # in the table should be in the past by a safe margin.
134
+ # * <tt>valid_until</tt> -- A column of type <tt>datetime</tt>, which contains the moment from
135
+ # which the rate is no longer valid. It may be <tt>NULL</tt>, in which case the the rate is
136
+ # taken to be "valid until further notice". If it is not <tt>NULL</tt>, it must contain a
137
+ # date strictly later than <tt>valid_from</tt>.
138
+ # * <tt>replaced_by_id</tt> -- An integer, foreign key reference to the <tt>id</tt> column in
139
+ # this same table. If <tt>valid_until</tt> is <tt>NULL</tt>, <tt>replaced_by_id</tt> must also
140
+ # be <tt>NULL</tt>. If <tt>valid_until</tt> is non-<tt>NULL</tt>, <tt>replaced_by_id</tt> may
141
+ # or may not be <tt>NULL</tt>; if it refers to a replacement object, the <tt>valid_from</tt>
142
+ # value of that replacement object must be equal to the <tt>valid_until</tt> value of this
143
+ # object.
144
+ #
145
+ # Optionally, the table may have further columns:
146
+ # * <tt>value</tt> -- The actual (usually numeric) value for which we're going to all this
147
+ # effort, e.g. a tax rate percentage or a price in some currency unit.
148
+ # * <tt>is_default</tt> -- A boolean column indicating whether or not this object should be
149
+ # considered a default during its period of validity. This may be useful if there are several
150
+ # different rates in effect at the same time (such as standard, reduced and zero rate in the
151
+ # example above). If this column is used, there should be exactly one default rate at any
152
+ # given point in time, otherwise results are undefined.
153
+ #
154
+ # Apart from these requirements, a +TimeDependent+ object is a normal model object, and you may
155
+ # give it whatever extra metadata you want, and make references to it from any other model object.
156
+ module TimeDependent
157
+
158
+ module ActMethods
159
+ # Identifies the current model object as a +TimeDependent+ object, and creates all the
160
+ # necessary methods.
161
+ #
162
+ # Accepts options in a hash, all of which are optional:
163
+ # * <tt>id</tt> -- Alternative name for the <tt>id</tt> column
164
+ # * <tt>valid_from</tt> -- Alternative name for the <tt>valid_from</tt> column
165
+ # * <tt>valid_until</tt> -- Alternative name for the <tt>valid_until</tt> column
166
+ # * <tt>replaced_by_id</tt> -- Alternative name for the <tt>replaced_by_id</tt> column
167
+ # * <tt>value</tt> -- Alternative name for the <tt>value</tt> column
168
+ # * <tt>is_default</tt> -- Alternative name for the <tt>is_default</tt> column
169
+ #
170
+ # Example:
171
+ #
172
+ # class CommissionRate < ActiveRecord::Base
173
+ # acts_as_time_dependent :value => :rate
174
+ # belongs_to :referral_program
175
+ # named_scope :for_referral_program, lambda { |p| { :conditions => { :referral_program_id => p.id } } }
176
+ # end
177
+ #
178
+ # reseller_program = ReferralProgram.find(1)
179
+ # current_commission = CommissionRate.for_referral_program(reseller_program).default_record_now
180
+ # puts "Earn #{current_commission.rate} per cent commission as a reseller..."
181
+ #
182
+ # changes = current_commission.changes_until(1.year.from_now)
183
+ # for next_commission in changes
184
+ # message = next_commission.nil? ? "Discontinued as of" : "Changing to #{next_commission.rate} per cent on"
185
+ # puts "#{message} #{current_commission.valid_until.strftime('%d %b %Y')}!"
186
+ # current_commission = next_commission
187
+ # end
188
+ #
189
+ def acts_as_time_dependent(*args)
190
+ # Activate CachedRecord first, because ClassInfo#initialize expects the cache to be ready
191
+ acts_as_cached_record(*args)
192
+
193
+ Invoicing::ClassInfo.acts_as(Invoicing::TimeDependent, self, args)
194
+
195
+ # Create replaced_by association if it doesn't exist yet
196
+ replaced_by_id = time_dependent_class_info.method(:replaced_by_id)
197
+ unless respond_to? :replaced_by
198
+ belongs_to :replaced_by, :class_name => class_name, :foreign_key => replaced_by_id
199
+ end
200
+
201
+ # Create value_at and value_now method aliases
202
+ value_method = time_dependent_class_info.method(:value).to_s
203
+ if value_method != 'value'
204
+ alias_method(value_method + '_at', :value_at)
205
+ alias_method(value_method + '_now', :value_now)
206
+ class_eval <<-ALIAS
207
+ class << self
208
+ alias_method('default_#{value_method}_at', :default_value_at)
209
+ alias_method('default_#{value_method}_now', :default_value_now)
210
+ end
211
+ ALIAS
212
+ end
213
+ end # acts_as_time_dependent
214
+ end # module ActMethods
215
+
216
+
217
+ module ClassMethods
218
+ # Returns a list of records which are valid at some point during a particular date/time
219
+ # range. If there is a change of rate during this time interval, and one rate replaces
220
+ # another, then only the earliest element of each replacement chain is returned
221
+ # (because we can unambiguously convert from an earlier rate to a later one, but
222
+ # not necessarily in reverse).
223
+ #
224
+ # The date range must not be empty (i.e. +not_after+ must be later than +not_before+,
225
+ # not the same time or earlier). If you need the records which are valid at one
226
+ # particular point in time, use +valid_records_at+.
227
+ #
228
+ # A typical application for this method would be where you want to offer users the
229
+ # ability to choose from a selection of rates, including ones which are not yet
230
+ # valid but will become valid within the next month, for example.
231
+ def valid_records_during(not_before, not_after)
232
+ info = time_dependent_class_info
233
+
234
+ # List of all records whose validity period intersects the selected period
235
+ valid_records = cached_record_list.select do |record|
236
+ valid_from = info.get(record, :valid_from)
237
+ valid_until = info.get(record, :valid_until)
238
+ has_taken_effect = (valid_from < not_after) # N.B. less than
239
+ not_yet_expired = (valid_until == nil) || (valid_until > not_before)
240
+ has_taken_effect && not_yet_expired
241
+ end
242
+
243
+ # Select only those which do not have a predecessor which is also valid
244
+ valid_records.select do |record|
245
+ record.predecessors.empty? || (valid_records & record.predecessors).empty?
246
+ end
247
+ end
248
+
249
+ # Returns the list of all records which are valid at one particular point in time.
250
+ # If you need to consider a period of time rather than a point in time, use
251
+ # +valid_records_during+.
252
+ def valid_records_at(point_in_time)
253
+ info = time_dependent_class_info
254
+ cached_record_list.select do |record|
255
+ valid_from = info.get(record, :valid_from)
256
+ valid_until = info.get(record, :valid_until)
257
+ has_taken_effect = (valid_from <= point_in_time) # N.B. less than or equals
258
+ not_yet_expired = (valid_until == nil) || (valid_until > point_in_time)
259
+ has_taken_effect && not_yet_expired
260
+ end
261
+ end
262
+
263
+ # Returns the default record which is valid at a particular point in time.
264
+ # If there is no record marked as default, nil is returned; if there are
265
+ # multiple records marked as default, results are undefined.
266
+ # This method only works if the model objects have an +is_default+ column.
267
+ def default_record_at(point_in_time)
268
+ info = time_dependent_class_info
269
+ valid_records_at(point_in_time).select{|record| info.get(record, :is_default)}.first
270
+ end
271
+
272
+ # Returns the default record which is valid at the current moment.
273
+ def default_record_now
274
+ default_record_at(Time.now)
275
+ end
276
+
277
+ # Finds the default record for a particular +point_in_time+ (using +default_record_at+),
278
+ # then returns the value of that record's +value+ column. If +value+ was renamed to
279
+ # +another_method_name+ (option to +acts_as_time_dependent+), then
280
+ # +default_another_method_name_at+ is defined as an alias for +default_value_at+.
281
+ def default_value_at(point_in_time)
282
+ time_dependent_class_info.get(default_record_at(point_in_time), :value)
283
+ end
284
+
285
+ # Finds the current default record (like +default_record_now+),
286
+ # then returns the value of that record's +value+ column. If +value+ was renamed to
287
+ # +another_method_name+ (option to +acts_as_time_dependent+), then
288
+ # +default_another_method_name_now+ is defined as an alias for +default_value_now+.
289
+ def default_value_now
290
+ default_value_at(Time.now)
291
+ end
292
+
293
+ end # module ClassMethods
294
+
295
+ # Returns a list of objects of the same type as this object, which refer to this object
296
+ # through their +replaced_by_id+ values. In other words, this method returns all records
297
+ # which are direct predecessors of the current record in the replacement chain.
298
+ def predecessors
299
+ time_dependent_class_info.predecessors(self)
300
+ end
301
+
302
+ # Translates this record into its replacement for a given point in time, if necessary/possible.
303
+ #
304
+ # * If this record is still valid at the given date/time, this method just returns self.
305
+ # * If this record is no longer valid at the given date/time, the record which has been
306
+ # marked as this rate's replacement for the given point in time is returned.
307
+ # * If this record has expired and there is no valid replacement, nil is returned.
308
+ # * On the other hand, if the given date is at a time before this record becomes valid,
309
+ # we try to follow the chain of +predecessors+ records. If there is an unambiguous predecessor
310
+ # record which is valid at the given point in time, it is returned; otherwise nil is returned.
311
+ def record_at(point_in_time)
312
+ valid_from = time_dependent_class_info.get(self, :valid_from)
313
+ valid_until = time_dependent_class_info.get(self, :valid_until)
314
+
315
+ if valid_from > point_in_time
316
+ (predecessors.size == 1) ? predecessors[0].record_at(point_in_time) : nil
317
+ elsif valid_until.nil? || (valid_until > point_in_time)
318
+ self
319
+ elsif replaced_by.nil?
320
+ nil
321
+ else
322
+ replaced_by.record_at(point_in_time)
323
+ end
324
+ end
325
+
326
+ # Returns self if this record is currently valid, otherwise its past or future replacement
327
+ # (see +record_at+). If there is no valid replacement, nil is returned.
328
+ def record_now
329
+ record_at Time.now
330
+ end
331
+
332
+ # Finds this record's replacement for a given point in time (see +record_at+), then returns
333
+ # the value in its +value+ column. If +value+ was renamed to +another_method_name+ (option to
334
+ # +acts_as_time_dependent+), then +another_method_name_at+ is defined as an alias for +value_at+.
335
+ def value_at(point_in_time)
336
+ time_dependent_class_info.get(record_at(point_in_time), :value)
337
+ end
338
+
339
+ # Returns +value_at+ for the current date/time. If +value+ was renamed to +another_method_name+
340
+ # (option to +acts_as_time_dependent+), then +another_method_name_now+ is defined as an alias for
341
+ # +value_now+.
342
+ def value_now
343
+ value_at Time.now
344
+ end
345
+
346
+ # Examines the replacement chain from this record into the future, during the period
347
+ # starting with this record's +valid_from+ and ending at +point_in_time+.
348
+ # If this record stays valid until after +point_in_time+, an empty list is returned.
349
+ # Otherwise the sequence of replacement records is returned in the list. If a record
350
+ # expires before +point_in_time+ and without replacement, a +nil+ element is inserted
351
+ # as the last element of the list.
352
+ def changes_until(point_in_time)
353
+ info = time_dependent_class_info
354
+ changes = []
355
+ record = self
356
+ while !record.nil?
357
+ valid_until = info.get(record, :valid_until)
358
+ break if valid_until.nil? || (valid_until > point_in_time)
359
+ record = record.replaced_by
360
+ changes << record
361
+ end
362
+ changes
363
+ end
364
+
365
+
366
+ # Stores state in the ActiveRecord class object
367
+ class ClassInfo < Invoicing::ClassInfo::Base #:nodoc:
368
+
369
+ def initialize(model_class, previous_info, args)
370
+ super
371
+ # @predecessors is a hash of an ID pointing to the list of all objects which have that ID
372
+ # as replaced_by_id value
373
+ @predecessors = {}
374
+ for record in model_class.cached_record_list
375
+ id = get(record, :replaced_by_id)
376
+ unless id.nil?
377
+ @predecessors[id] ||= []
378
+ @predecessors[id] << record
379
+ end
380
+ end
381
+ end
382
+
383
+ def predecessors(record)
384
+ @predecessors[get(record, :id)] || []
385
+ end
386
+ end # class ClassInfo
387
+ end # module TimeDependent
388
+ end