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.
- 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
|