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

Sign up to get free protection for your applications and to get access to all the features.
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