double_entry 0.0.1.pre → 0.1.0.pre.pre.alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. checksums.yaml +13 -5
  2. data/.gitignore +5 -6
  3. data/.rspec +1 -0
  4. data/.travis.yml +19 -0
  5. data/.yardopts +2 -0
  6. data/Gemfile +0 -1
  7. data/LICENSE.md +19 -0
  8. data/README.md +221 -14
  9. data/Rakefile +12 -0
  10. data/double_entry.gemspec +30 -15
  11. data/gemfiles/Gemfile.rails-3.2.0 +5 -0
  12. data/gemfiles/Gemfile.rails-4.0.0 +5 -0
  13. data/gemfiles/Gemfile.rails-4.1.0 +5 -0
  14. data/lib/active_record/locking_extensions.rb +61 -0
  15. data/lib/double_entry.rb +267 -2
  16. data/lib/double_entry/account.rb +82 -0
  17. data/lib/double_entry/account_balance.rb +31 -0
  18. data/lib/double_entry/aggregate.rb +118 -0
  19. data/lib/double_entry/aggregate_array.rb +65 -0
  20. data/lib/double_entry/configurable.rb +52 -0
  21. data/lib/double_entry/day_range.rb +38 -0
  22. data/lib/double_entry/hour_range.rb +40 -0
  23. data/lib/double_entry/line.rb +147 -0
  24. data/lib/double_entry/line_aggregate.rb +37 -0
  25. data/lib/double_entry/line_check.rb +118 -0
  26. data/lib/double_entry/locking.rb +187 -0
  27. data/lib/double_entry/month_range.rb +92 -0
  28. data/lib/double_entry/reporting.rb +16 -0
  29. data/lib/double_entry/time_range.rb +55 -0
  30. data/lib/double_entry/time_range_array.rb +43 -0
  31. data/lib/double_entry/transfer.rb +70 -0
  32. data/lib/double_entry/version.rb +3 -1
  33. data/lib/double_entry/week_range.rb +99 -0
  34. data/lib/double_entry/year_range.rb +39 -0
  35. data/lib/generators/double_entry/install/install_generator.rb +22 -0
  36. data/lib/generators/double_entry/install/templates/migration.rb +68 -0
  37. data/script/jack_hammer +201 -0
  38. data/script/setup.sh +8 -0
  39. data/spec/active_record/locking_extensions_spec.rb +54 -0
  40. data/spec/double_entry/account_balance_spec.rb +8 -0
  41. data/spec/double_entry/account_spec.rb +23 -0
  42. data/spec/double_entry/aggregate_array_spec.rb +75 -0
  43. data/spec/double_entry/aggregate_spec.rb +168 -0
  44. data/spec/double_entry/double_entry_spec.rb +391 -0
  45. data/spec/double_entry/line_aggregate_spec.rb +8 -0
  46. data/spec/double_entry/line_check_spec.rb +88 -0
  47. data/spec/double_entry/line_spec.rb +72 -0
  48. data/spec/double_entry/locking_spec.rb +154 -0
  49. data/spec/double_entry/month_range_spec.rb +131 -0
  50. data/spec/double_entry/reporting_spec.rb +25 -0
  51. data/spec/double_entry/time_range_array_spec.rb +149 -0
  52. data/spec/double_entry/time_range_spec.rb +43 -0
  53. data/spec/double_entry/week_range_spec.rb +88 -0
  54. data/spec/generators/double_entry/install/install_generator_spec.rb +33 -0
  55. data/spec/spec_helper.rb +47 -0
  56. data/spec/support/accounts.rb +26 -0
  57. data/spec/support/blueprints.rb +34 -0
  58. data/spec/support/database.example.yml +16 -0
  59. data/spec/support/database.travis.yml +18 -0
  60. data/spec/support/double_entry_spec_helper.rb +19 -0
  61. data/spec/support/reporting_configuration.rb +6 -0
  62. data/spec/support/schema.rb +71 -0
  63. metadata +277 -18
  64. data/LICENSE.txt +0 -22
@@ -0,0 +1,65 @@
1
+ # encoding: utf-8
2
+ module DoubleEntry
3
+ class AggregateArray < Array
4
+ # An AggregateArray is awesome
5
+ # It is useful for making reports
6
+ # It is basically an array of aggregate results,
7
+ # representing a column of data in a report.
8
+ #
9
+ # For example, you could request all sales
10
+ # broken down by month and it would return an array of values
11
+ attr_reader :function, :account, :code, :filter, :range_type, :start, :finish
12
+
13
+ def initialize(function, account, code, options)
14
+ @function = function
15
+ @account = account
16
+ @code = code
17
+ @filter = options[:filter]
18
+ @range_type = options[:range_type]
19
+ @start = options[:start]
20
+ @finish = options[:finish]
21
+
22
+ retrieve_aggregates
23
+ fill_in_missing_aggregates
24
+ populate_self
25
+ end
26
+
27
+ private
28
+
29
+ def populate_self
30
+ all_periods.each do |period|
31
+ self << @aggregates[period.key]
32
+ end
33
+ end
34
+
35
+ def fill_in_missing_aggregates
36
+ # some aggregates may not have been previously calculated, so we can request them now
37
+ # (this includes aggregates for the still-running period)
38
+ all_periods.each do |period|
39
+ unless @aggregates[period.key]
40
+ @aggregates[period.key] = DoubleEntry.aggregate(function, account, code, :filter => filter, :range => period)
41
+ end
42
+ end
43
+ end
44
+
45
+ # get any previously calculated aggregates
46
+ def retrieve_aggregates
47
+ raise ArgumentError.new("Invalid range type '#{range_type}'") unless %w(year month week day hour).include? range_type
48
+ @aggregates = LineAggregate.
49
+ where(:function => function.to_s).
50
+ where(:range_type => 'normal').
51
+ where(:account => account.to_s).
52
+ where(:code => code.to_s).
53
+ where(:filter => filter.inspect).
54
+ where(LineAggregate.arel_table[range_type].not_eq(nil)).
55
+ inject({}) do |hash, result|
56
+ hash[result.key] = Aggregate.formatted_amount(function, result.amount)
57
+ hash
58
+ end
59
+ end
60
+
61
+ def all_periods
62
+ TimeRangeArray.make(range_type, start, finish)
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,52 @@
1
+ # encoding: utf-8
2
+ module DoubleEntry
3
+
4
+ # Make configuring a module or a class simple.
5
+ #
6
+ # class MyClass
7
+ # include Configurable
8
+ #
9
+ # class Configuration
10
+ # attr_accessor :my_config_option
11
+ #
12
+ # def initialize #:nodoc:
13
+ # @my_config_option = "default value"
14
+ # end
15
+ # end
16
+ # end
17
+ #
18
+ # Then in an initializer (or environments/*.rb) do:
19
+ #
20
+ # MyClass.configure do |config|
21
+ # config.my_config_option = "custom value"
22
+ # end
23
+ #
24
+ # And inside methods in your class you can access your config:
25
+ #
26
+ # class MyClass
27
+ # def my_method
28
+ # puts configuration.my_config_option
29
+ # end
30
+ # end
31
+ #
32
+ # This is all based on this article:
33
+ #
34
+ # http://robots.thoughtbot.com/post/344833329/mygem-configure-block
35
+ #
36
+ module Configurable
37
+ def self.included(base) #:nodoc:
38
+ base.extend(ClassMethods)
39
+ end
40
+
41
+ module ClassMethods #:nodoc:
42
+ def configuration
43
+ @configuration ||= self::Configuration.new
44
+ end
45
+
46
+ def configure
47
+ yield(configuration)
48
+ end
49
+ end
50
+ end
51
+
52
+ end
@@ -0,0 +1,38 @@
1
+ # encoding: utf-8
2
+ module DoubleEntry
3
+ class DayRange < TimeRange
4
+ attr_reader :year, :week, :day
5
+
6
+ def initialize(options)
7
+ super options
8
+
9
+ @week = options[:week]
10
+ @day = options[:day]
11
+ week_range = WeekRange.new(options)
12
+
13
+ @start = week_range.start + (options[:day] - 1).days
14
+ @finish = @start.end_of_day
15
+ end
16
+
17
+ def self.from_time(time)
18
+ week_range = WeekRange.from_time(time)
19
+ DayRange.new(:year => week_range.year, :week => week_range.week, :day => time.wday == 0 ? 7 : time.wday)
20
+ end
21
+
22
+ def previous
23
+ DayRange.from_time(@start - 1.day)
24
+ end
25
+
26
+ def next
27
+ DayRange.from_time(@start + 1.day)
28
+ end
29
+
30
+ def ==(other)
31
+ (self.week == other.week) and (self.year == other.year) and (self.day == other.day)
32
+ end
33
+
34
+ def to_s
35
+ start.strftime('%Y, %a %b %d')
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,40 @@
1
+ # encoding: utf-8
2
+ module DoubleEntry
3
+ class HourRange < TimeRange
4
+ attr_reader :year, :week, :day, :hour
5
+
6
+ def initialize(options)
7
+ super options
8
+
9
+ @week = options[:week]
10
+ @day = options[:day]
11
+ @hour = options[:hour]
12
+
13
+ day_range = DayRange.new(options)
14
+
15
+ @start = day_range.start + options[:hour].hours
16
+ @finish = @start.end_of_hour
17
+ end
18
+
19
+ def self.from_time(time)
20
+ day = DayRange.from_time(time)
21
+ HourRange.new :year => day.year, :week => day.week, :day => day.day, :hour => time.hour
22
+ end
23
+
24
+ def previous
25
+ HourRange.from_time(@start - 1.hour)
26
+ end
27
+
28
+ def next
29
+ HourRange.from_time(@start + 1.hour)
30
+ end
31
+
32
+ def ==(other)
33
+ (self.week == other.week) and (self.year == other.year) and (self.day == other.day) and (self.hour == other.hour)
34
+ end
35
+
36
+ def to_s
37
+ "#{start.hour}:00:00 - #{start.hour}:59:59"
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,147 @@
1
+ # encoding: utf-8
2
+
3
+ module DoubleEntry
4
+
5
+ # This is the table to end all tables!
6
+ #
7
+ # Every financial transaction gets two entries in here: one for the source
8
+ # account, and one for the destination account. Normal double-entry
9
+ # accounting principles are followed.
10
+ #
11
+ # This is a log table, and should (ideally) never be updated.
12
+ #
13
+ # ## Indexes
14
+ #
15
+ # The indexes on this table are carefully chosen, as it's both big and heavily loaded.
16
+ #
17
+ # ### lines_scope_account_id_idx
18
+ #
19
+ # ```sql
20
+ # ADD INDEX `lines_scope_account_id_idx` (scope, account, id)
21
+ # ```
22
+ #
23
+ # This is the important one. It's used primarily for querying the current
24
+ # balance of an account. eg:
25
+ #
26
+ # ```sql
27
+ # SELECT * FROM `lines` WHERE scope = ? AND account = ? ORDER BY id DESC LIMIT 1
28
+ # ```
29
+ #
30
+ # ### lines_scope_account_created_at_idx
31
+ #
32
+ # ```sql
33
+ # ADD INDEX `lines_scope_account_created_at_idx` (scope, account, created_at)
34
+ # ```
35
+ #
36
+ # Used for querying historic balances:
37
+ #
38
+ # ```sql
39
+ # SELECT * FROM `lines` WHERE scope = ? AND account = ? AND created_at < ? ORDER BY id DESC LIMIT 1
40
+ # ```
41
+ #
42
+ # And for reporting on account changes over a time period:
43
+ #
44
+ # ```sql
45
+ # SELECT SUM(amount) FROM `lines` WHERE scope = ? AND account = ? AND created_at BETWEEN ? AND ?
46
+ # ```
47
+ #
48
+ # ### lines_account_created_at_idx and lines_account_code_created_at_idx
49
+ #
50
+ # ```sql
51
+ # ADD INDEX `lines_account_created_at_idx` (account, created_at);
52
+ # ADD INDEX `lines_account_code_created_at_idx` (account, code, created_at);
53
+ # ```
54
+ #
55
+ # These two are used for generating reports, which need to sum things
56
+ # by account, or account and code, over a particular period.
57
+ #
58
+ class Line < ActiveRecord::Base
59
+ extend EncapsulateAsMoney
60
+
61
+ belongs_to :detail, :polymorphic => true
62
+ before_save :check_balance_will_not_be_sent_negative
63
+
64
+ encapsulate_as_money :amount, :balance
65
+
66
+ def code=(code)
67
+ self[:code] = code.try(:to_s)
68
+ code
69
+ end
70
+
71
+ def code
72
+ self[:code].try(:to_sym)
73
+ end
74
+
75
+ def meta=(meta)
76
+ self[:meta] = Marshal.dump(meta)
77
+ meta
78
+ end
79
+
80
+ def meta
81
+ meta = self[:meta]
82
+ meta ? Marshal.load(meta) : {}
83
+ end
84
+
85
+ def account=(account)
86
+ self[:account] = account.identifier.to_s
87
+ self.scope = account.scope_identity
88
+ account
89
+ end
90
+
91
+ def account
92
+ DoubleEntry.account(self[:account].to_sym, :scope => scope)
93
+ end
94
+
95
+ def partner_account=(partner_account)
96
+ self[:partner_account] = partner_account.identifier.to_s
97
+ self.partner_scope = partner_account.scope_identity
98
+ partner_account
99
+ end
100
+
101
+ def partner_account
102
+ DoubleEntry.account(self[:partner_account].to_sym, :scope => partner_scope)
103
+ end
104
+
105
+ def partner
106
+ self.class.find(partner_id)
107
+ end
108
+
109
+ def pair
110
+ if credit?
111
+ [self, partner]
112
+ else
113
+ [partner, self]
114
+ end
115
+ end
116
+
117
+ def credit?
118
+ amount < Money.empty
119
+ end
120
+
121
+ def debit?
122
+ amount > Money.empty
123
+ end
124
+
125
+ def description
126
+ DoubleEntry.describe(self)
127
+ end
128
+
129
+ # Query out just the id and created_at fields for lines, without
130
+ # instantiating any ActiveRecord objects.
131
+ def self.find_id_and_created_at(options)
132
+ connection.select_rows <<-SQL
133
+ SELECT id, created_at FROM #{Line.quoted_table_name} #{options[:joins]}
134
+ WHERE #{sanitize_sql_for_conditions(options[:conditions])}
135
+ SQL
136
+ end
137
+
138
+ private
139
+
140
+ def check_balance_will_not_be_sent_negative
141
+ if self.account.positive_only and self.balance < Money.new(0)
142
+ raise AccountWouldBeSentNegative.new(account)
143
+ end
144
+ end
145
+ end
146
+
147
+ end
@@ -0,0 +1,37 @@
1
+ # encoding: utf-8
2
+ module DoubleEntry
3
+ class LineAggregate < ActiveRecord::Base
4
+ extend EncapsulateAsMoney
5
+
6
+ def self.aggregate(function, account, code, scope, range, named_scopes)
7
+ collection = aggregate_collection(named_scopes)
8
+ collection = collection.where(:account => account)
9
+ collection = collection.where(:created_at => range.start..range.finish)
10
+ collection = collection.where(:code => code) if code
11
+ collection.send(function, :amount)
12
+ end
13
+
14
+ # a lot of the trickier reports will use filters defined
15
+ # in named_scopes to bring in data from other tables.
16
+ def self.aggregate_collection(named_scopes)
17
+ if named_scopes
18
+ collection = Line
19
+ named_scopes.each do |named_scope|
20
+ if named_scope.is_a?(Hash)
21
+ method_name = named_scope.keys[0]
22
+ collection = collection.send(method_name, named_scope[method_name])
23
+ else
24
+ collection = collection.send(named_scope)
25
+ end
26
+ end
27
+ collection
28
+ else
29
+ Line
30
+ end
31
+ end
32
+
33
+ def key
34
+ "#{year}:#{month}:#{week}:#{day}:#{hour}"
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,118 @@
1
+ # encoding: utf-8
2
+ require 'set'
3
+
4
+ module DoubleEntry
5
+ class LineCheck < ActiveRecord::Base
6
+ extend EncapsulateAsMoney
7
+
8
+ default_scope -> { order('created_at') }
9
+
10
+ def self.perform!
11
+ new.perform
12
+ end
13
+
14
+ def perform
15
+ log = ''
16
+ current_line_id = nil
17
+
18
+ active_accounts = Set.new
19
+ incorrect_accounts = Set.new
20
+
21
+ new_lines_since_last_run.find_each do |line|
22
+ incorrect_accounts << line.account unless running_balance_correct?(line, log)
23
+ active_accounts << line.account
24
+ current_line_id = line.id
25
+ end
26
+
27
+ active_accounts.each do |account|
28
+ incorrect_accounts << account unless cached_balance_correct?(account)
29
+ end
30
+
31
+ incorrect_accounts.each { |account| recalculate_account(account) }
32
+
33
+ unless active_accounts.empty?
34
+ LineCheck.create!(
35
+ :errors_found => incorrect_accounts.any?,
36
+ :last_line_id => current_line_id,
37
+ :log => log,
38
+ )
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def last_run_line_id
45
+ latest = LineCheck.last
46
+ latest ? latest.last_line_id : 0
47
+ end
48
+
49
+ def new_lines_since_last_run
50
+ Line.where('id > ?', last_run_line_id)
51
+ end
52
+
53
+ def running_balance_correct?(line, log)
54
+ # Another work around for the MySQL 5.1 query optimiser bug that causes the ORDER BY
55
+ # on the query to fail in some circumstances, resulting in an old balance being
56
+ # returned. This was biting us intermittently in spec runs.
57
+ # See http://bugs.mysql.com/bug.php?id=51431
58
+ force_index = if Line.connection.adapter_name.match /mysql/i
59
+ "FORCE INDEX (lines_scope_account_id_idx)"
60
+ else
61
+ ""
62
+ end
63
+
64
+ # yes, it needs to be find_by_sql, because any other find will be affected
65
+ # by the find_each call in perform!
66
+ previous_line = Line.find_by_sql(["SELECT * FROM #{Line.quoted_table_name} #{force_index} WHERE account = ? AND scope = ? AND id < ? ORDER BY id DESC LIMIT 1", line.account.identifier.to_s, line.scope, line.id])
67
+ previous_balance = previous_line.length == 1 ? previous_line[0].balance : Money.empty
68
+
69
+ if line.balance != (line.amount + previous_balance)
70
+ log << line_error_message(line, previous_line, previous_balance)
71
+ end
72
+
73
+ line.balance == previous_balance + line.amount
74
+ end
75
+
76
+ def line_error_message(line, previous_line, previous_balance)
77
+ <<-END_OF_MESSAGE.strip_heredoc
78
+ *********************************
79
+ Error on line ##{line.id}: balance:#{line.balance} != #{previous_balance} + #{line.amount}
80
+ *********************************
81
+ #{previous_line.inspect}
82
+ #{line.inspect}
83
+
84
+ END_OF_MESSAGE
85
+ end
86
+
87
+ def cached_balance_correct?(account)
88
+ DoubleEntry.lock_accounts(account) do
89
+ return AccountBalance.find_by_account(account).balance == account.balance
90
+ end
91
+ end
92
+
93
+ def recalculate_account(account)
94
+ DoubleEntry.lock_accounts(account) do
95
+ recalculated_balance = Money.empty
96
+
97
+ lines_for_account(account).each do |line|
98
+ recalculated_balance += line.amount
99
+ line.update_attribute(:balance, recalculated_balance) if line.balance != recalculated_balance
100
+ end
101
+
102
+ update_balance_for_account(account, recalculated_balance)
103
+ end
104
+ end
105
+
106
+ def lines_for_account(account)
107
+ Line.where(
108
+ :account => account.identifier.to_s,
109
+ :scope => account.scope_identity.to_s
110
+ ).order(:id)
111
+ end
112
+
113
+ def update_balance_for_account(account, balance)
114
+ account_balance = Locking.balance_for_locked_account(account)
115
+ account_balance.update_attribute(:balance, balance)
116
+ end
117
+ end
118
+ end