invoicing 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGELOG +3 -0
- data/LICENSE +20 -0
- data/Manifest +60 -0
- data/README +48 -0
- data/Rakefile +75 -0
- data/invoicing.gemspec +41 -0
- data/lib/invoicing.rb +9 -0
- data/lib/invoicing/cached_record.rb +107 -0
- data/lib/invoicing/class_info.rb +187 -0
- data/lib/invoicing/connection_adapter_ext.rb +44 -0
- data/lib/invoicing/countries/uk.rb +24 -0
- data/lib/invoicing/currency_value.rb +212 -0
- data/lib/invoicing/find_subclasses.rb +193 -0
- data/lib/invoicing/ledger_item.rb +718 -0
- data/lib/invoicing/ledger_item/render_html.rb +515 -0
- data/lib/invoicing/ledger_item/render_ubl.rb +268 -0
- data/lib/invoicing/line_item.rb +246 -0
- data/lib/invoicing/price.rb +9 -0
- data/lib/invoicing/tax_rate.rb +9 -0
- data/lib/invoicing/taxable.rb +355 -0
- data/lib/invoicing/time_dependent.rb +388 -0
- data/lib/invoicing/version.rb +21 -0
- data/test/cached_record_test.rb +100 -0
- data/test/class_info_test.rb +253 -0
- data/test/connection_adapter_ext_test.rb +71 -0
- data/test/currency_value_test.rb +184 -0
- data/test/find_subclasses_test.rb +120 -0
- data/test/fixtures/README +7 -0
- data/test/fixtures/cached_record.sql +22 -0
- data/test/fixtures/class_info.sql +28 -0
- data/test/fixtures/currency_value.sql +29 -0
- data/test/fixtures/find_subclasses.sql +43 -0
- data/test/fixtures/ledger_item.sql +39 -0
- data/test/fixtures/line_item.sql +33 -0
- data/test/fixtures/price.sql +4 -0
- data/test/fixtures/tax_rate.sql +4 -0
- data/test/fixtures/taxable.sql +14 -0
- data/test/fixtures/time_dependent.sql +35 -0
- data/test/ledger_item_test.rb +352 -0
- data/test/line_item_test.rb +139 -0
- data/test/models/README +4 -0
- data/test/models/test_subclass_in_another_file.rb +3 -0
- data/test/models/test_subclass_not_in_database.rb +6 -0
- data/test/price_test.rb +9 -0
- data/test/ref-output/creditnote3.html +82 -0
- data/test/ref-output/creditnote3.xml +89 -0
- data/test/ref-output/invoice1.html +93 -0
- data/test/ref-output/invoice1.xml +111 -0
- data/test/ref-output/invoice2.html +86 -0
- data/test/ref-output/invoice2.xml +98 -0
- data/test/ref-output/invoice_null.html +36 -0
- data/test/render_html_test.rb +69 -0
- data/test/render_ubl_test.rb +32 -0
- data/test/setup.rb +37 -0
- data/test/tax_rate_test.rb +9 -0
- data/test/taxable_test.rb +180 -0
- data/test/test_helper.rb +48 -0
- data/test/time_dependent_test.rb +180 -0
- data/website/curvycorners.js +1 -0
- data/website/screen.css +149 -0
- data/website/template.html.erb +43 -0
- metadata +180 -0
@@ -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
|