Module: Invoicing::TimeDependent
- Defined in:
- lib/invoicing/time_dependent.rb
Overview
Time-dependent value objects
This module implements the notion of a value (or a set of values) which may change at certain points in time, and for which it is important to have a full history of values for every point in time. It is used in the invoicing framework as basis for tax rates, prices, commissions etc.
Background
To illustrate the need for this tool, consider for example the case of a tax rate. Say the rate is currently 10%, and in a naive implementation you simply store the value 0.1
in a constant. Whenever you need to calculate tax on a price, you multiply the price with the constant, and store the result together with the price in the database. Then, one day the government decides to increase the tax rate to 12%. On the day the change takes effect, you change the value of the constant to 0.12
.
This naive implementation has a number of problems, which are addressed by this module:
-
With a constant, you have no way of informing users what a price will be after an upcoming tax change. Using
TimeDependent
allows you to query the value on any date in the past or future, and show it to users as appropriate. You also gain the ability to process back-dated or future-dated transactions if this should be necessary. -
With a constant, you have no explicit information in your database informing you which rate was applied for a particular tax calculation. You may be able to infer the rate from the prices you store, but this may not be enough in cases where there is additional metadata attached to tax rates (e.g. if there are different tax rates for different types of product). With
TimeDependent
you can have an explicit reference to the tax object which formed the basis of a calculation, giving you a much better audit trail. -
If there are different tax categories (e.g. a reduced rate for products of type A, and a higher rate for type B), the government may not only change the rates themselves, but also decide to reclassify product X as type B rather than type A. In any case you will need to store the type of each of your products; however,
TimeDependent
tries to minimize the amount of reclassifying you need to do, should it become necessary.
Data Structure
TimeDependent
objects are special ActiveRecord::Base objects. One database table is used, and each row in that table represents the value (e.g. the tax rate or the price) during a particular period of time. If there are multiple different values at the same time (e.g. a reduced tax rate and a higher rate), each of these is also represented as a separate row. That way you can refer to a TimeDependent
object from another model object (such as storing the tax category for a product), and refer simultaneously to the type of tax applicable for this product and the period for which this classification is valid.
If a rate change is announced, it important that the actual values in the table are not changed in order to preserve historical information. Instead, add another row (or several rows), taking effect on the appropriate date. However, it is usually not necessary to update your other model objects to refer to these new rows; instead, each TimeDependent
object which expires has a reference to the new TimeDependent
objects which replaces it. TimeDependent
provides methods for finding the current (or future) rate by following this chain of replacements.
Example
To illustrate, take as example the rate of VAT (Value Added Tax) in the United Kingdom. The main tax rate was at 17.5% until 1 December 2008, when it was changed to 15%. On 1 January 2010 it is due to be changed back to 17.5%. At the same time, there are a reduced rates of 5% and 0% on certain goods; while the main rate was changed, the reduced rates stayed unchanged.
The table of TimeDependent
records will look something like this:
+----+-------+---------------+------------+---------------------+---------------------+----------------+
| id | value | description | is_default | valid_from | valid_until | replaced_by_id |
+----+-------+---------------+------------+---------------------+---------------------+----------------+
| 1 | 0.175 | Standard rate | 1 | 1991-04-01 00:00:00 | 2008-12-01 00:00:00 | 4 |
| 2 | 0.05 | Reduced rate | 0 | 1991-04-01 00:00:00 | NULL | NULL |
| 3 | 0.0 | Zero rate | 0 | 1991-04-01 00:00:00 | NULL | NULL |
| 4 | 0.15 | Standard rate | 1 | 2008-12-01 00:00:00 | 2010-01-01 00:00:00 | 5 |
| 5 | 0.175 | Standard rate | 1 | 2010-01-01 00:00:00 | NULL | NULL |
+----+-------+---------------+------------+---------------------+---------------------+----------------+
Graphically, this may be illustrated as:
1991-04-01 2008-12-01 2010-01-01
: : :
Standard rate: 17.5% -----------------> 15% ---------------> 17.5% ----------------->
: : :
Zero rate: 0% --------------------------------------------------------------->
: : :
Reduced rate: 5% --------------------------------------------------------------->
It is a deliberate choice that a TimeDependent
object references its successor, and not its predecessor. This is so that you can classify your items based on the current classification, and be sure that if the current rate expires there is an unambiguous replacement for it. On the other hand, it is usually not important to know what the rate for a particular item would have been at some point in the past.
Now consider a slightly more complicated (fictional) example, in which a UK court rules that teacakes have been incorrectly classified for VAT purposes, namely that they should have been zero-rated while actually they had been standard-rated. The court also decides that all sales of teacakes before 1 Dec 2008 should maintain their old standard-rated status, while sales from 1 Dec 2008 onwards should be zero-rated.
Assume you have an online shop in which you sell teacakes and other goods (both standard-rated and zero-rated). You can handle this reclassification (in addition to the standard VAT rate change above) as follows:
1991-04-01 2008-12-01 2010-01-01
: : :
Standard rate: 17.5% -----------------> 15% ---------------> 17.5% ----------------->
: : :
Teacakes: 17.5% ------------. : :
: \_ : :
Zero rate: 0% ---------------+-> 0% ---------------------------------------->
: : :
Reduced rate: 5% --------------------------------------------------------------->
Then you just need to update the teacake products in your database, which previously referred to the 17.5% object valid from 1991-04-01, to refer to the special teacake rate. None of the other products need to be modified. This way, the teacakes will automatically switch to the 0% rate on 2008-12-01. If you add any new teacake products to the database after December 2008, you can refer either to the teacake rate or to the new 0% rate which takes effect on 2008-12-01; it won’t make any difference.
Usage notes
This implementation is designed for tables with a small number of rows (no more than a few dozen) and very infrequent changes. To reduce database load, it caches model objects very aggressively; you will need to restart your Ruby interpreter after making a change to the data as the cache is not cleared between requests. This is ok because you shouldn’t be lightheartedly modifying TimeDependent
data anyway; a database migration as part of an explicitly deployed release is probably the best way of introducing a rate change (that way you can also check it all looks correct on your staging server before making the rate change public).
A model object using TimeDependent
must inherit from ActiveRecord::Base and must have at least the following columns (although columns may have different names, if declared to acts_as_time_dependent
):
-
id
– An integer primary key -
valid_from
– A column of typedatetime
, which must not beNULL
. It contains the moment at which the rate takes effect. The oldestvalid_from
dates in the table should be in the past by a safe margin. -
valid_until
– A column of typedatetime
, which contains the moment from which the rate is no longer valid. It may beNULL
, in which case the the rate is taken to be “valid until further notice”. If it is notNULL
, it must contain a date strictly later thanvalid_from
. -
replaced_by_id
– An integer, foreign key reference to theid
column in this same table. Ifvalid_until
isNULL
,replaced_by_id
must also beNULL
. Ifvalid_until
is non-NULL
,replaced_by_id
may or may not beNULL
; if it refers to a replacement object, thevalid_from
value of that replacement object must be equal to thevalid_until
value of this object.
Optionally, the table may have further columns:
-
value
– The actual (usually numeric) value for which we’re going to all this effort, e.g. a tax rate percentage or a price in some currency unit. -
is_default
– A boolean column indicating whether or not this object should be considered a default during its period of validity. This may be useful if there are several different rates in effect at the same time (such as standard, reduced and zero rate in the example above). If this column is used, there should be exactly one default rate at any given point in time, otherwise results are undefined.
Apart from these requirements, a TimeDependent
object is a normal model object, and you may give it whatever extra metadata you want, and make references to it from any other model object.
Defined Under Namespace
Modules: ActMethods, ClassMethods Classes: ClassInfo
Instance Method Summary collapse
-
#changes_until(point_in_time) ⇒ Object
Examines the replacement chain from this record into the future, during the period starting with this record’s
valid_from
and ending atpoint_in_time
. -
#predecessors ⇒ Object
Returns a list of objects of the same type as this object, which refer to this object through their
replaced_by_id
values. -
#record_at(point_in_time) ⇒ Object
Translates this record into its replacement for a given point in time, if necessary/possible.
-
#record_now ⇒ Object
Returns self if this record is currently valid, otherwise its past or future replacement (see
record_at
). -
#value_at(point_in_time) ⇒ Object
Finds this record’s replacement for a given point in time (see
record_at
), then returns the value in itsvalue
column. -
#value_now ⇒ Object
Returns
value_at
for the current date/time.
Instance Method Details
#changes_until(point_in_time) ⇒ Object
Examines the replacement chain from this record into the future, during the period starting with this record’s valid_from
and ending at point_in_time
. If this record stays valid until after point_in_time
, an empty list is returned. Otherwise the sequence of replacement records is returned in the list. If a record expires before point_in_time
and without replacement, a nil
element is inserted as the last element of the list.
352 353 354 355 356 357 358 359 360 361 362 363 |
# File 'lib/invoicing/time_dependent.rb', line 352 def changes_until(point_in_time) info = time_dependent_class_info changes = [] record = self while !record.nil? valid_until = info.get(record, :valid_until) break if valid_until.nil? || (valid_until > point_in_time) record = record.replaced_by changes << record end changes end |
#predecessors ⇒ Object
Returns a list of objects of the same type as this object, which refer to this object through their replaced_by_id
values. In other words, this method returns all records which are direct predecessors of the current record in the replacement chain.
298 299 300 |
# File 'lib/invoicing/time_dependent.rb', line 298 def predecessors time_dependent_class_info.predecessors(self) end |
#record_at(point_in_time) ⇒ Object
Translates this record into its replacement for a given point in time, if necessary/possible.
-
If this record is still valid at the given date/time, this method just returns self.
-
If this record is no longer valid at the given date/time, the record which has been marked as this rate’s replacement for the given point in time is returned.
-
If this record has expired and there is no valid replacement, nil is returned.
-
On the other hand, if the given date is at a time before this record becomes valid, we try to follow the chain of
predecessors
records. If there is an unambiguous predecessor record which is valid at the given point in time, it is returned; otherwise nil is returned.
311 312 313 314 315 316 317 318 319 320 321 322 323 324 |
# File 'lib/invoicing/time_dependent.rb', line 311 def record_at(point_in_time) valid_from = time_dependent_class_info.get(self, :valid_from) valid_until = time_dependent_class_info.get(self, :valid_until) if valid_from > point_in_time (predecessors.size == 1) ? predecessors[0].record_at(point_in_time) : nil elsif valid_until.nil? || (valid_until > point_in_time) self elsif replaced_by.nil? nil else replaced_by.record_at(point_in_time) end end |
#record_now ⇒ Object
Returns self if this record is currently valid, otherwise its past or future replacement (see record_at
). If there is no valid replacement, nil is returned.
328 329 330 |
# File 'lib/invoicing/time_dependent.rb', line 328 def record_now record_at Time.now end |
#value_at(point_in_time) ⇒ Object
Finds this record’s replacement for a given point in time (see record_at
), then returns the value in its value
column. If value
was renamed to another_method_name
(option to acts_as_time_dependent
), then another_method_name_at
is defined as an alias for value_at
.
335 336 337 |
# File 'lib/invoicing/time_dependent.rb', line 335 def value_at(point_in_time) time_dependent_class_info.get(record_at(point_in_time), :value) end |
#value_now ⇒ Object
Returns value_at
for the current date/time. If value
was renamed to another_method_name
(option to acts_as_time_dependent
), then another_method_name_now
is defined as an alias for value_now
.
342 343 344 |
# File 'lib/invoicing/time_dependent.rb', line 342 def value_now value_at Time.now end |