invoicing 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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