double_entry 1.0.1 → 2.0.0.beta1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (67) hide show
  1. checksums.yaml +5 -5
  2. data/CHANGELOG.md +432 -0
  3. data/README.md +36 -9
  4. data/double_entry.gemspec +20 -48
  5. data/lib/active_record/locking_extensions.rb +3 -3
  6. data/lib/active_record/locking_extensions/log_subscriber.rb +1 -1
  7. data/lib/double_entry/account.rb +38 -45
  8. data/lib/double_entry/account_balance.rb +18 -1
  9. data/lib/double_entry/errors.rb +13 -13
  10. data/lib/double_entry/line.rb +3 -2
  11. data/lib/double_entry/reporting.rb +26 -38
  12. data/lib/double_entry/reporting/aggregate.rb +43 -23
  13. data/lib/double_entry/reporting/aggregate_array.rb +16 -13
  14. data/lib/double_entry/reporting/line_aggregate.rb +3 -2
  15. data/lib/double_entry/reporting/line_aggregate_filter.rb +8 -10
  16. data/lib/double_entry/reporting/line_metadata_filter.rb +33 -0
  17. data/lib/double_entry/transfer.rb +33 -27
  18. data/lib/double_entry/validation.rb +1 -0
  19. data/lib/double_entry/validation/account_fixer.rb +36 -0
  20. data/lib/double_entry/validation/line_check.rb +22 -40
  21. data/lib/double_entry/version.rb +1 -1
  22. data/lib/generators/double_entry/install/install_generator.rb +7 -1
  23. data/lib/generators/double_entry/install/templates/migration.rb +27 -25
  24. metadata +33 -243
  25. data/.gitignore +0 -32
  26. data/.rspec +0 -2
  27. data/.travis.yml +0 -29
  28. data/.yardopts +0 -2
  29. data/Gemfile +0 -2
  30. data/Rakefile +0 -15
  31. data/script/jack_hammer +0 -210
  32. data/script/setup.sh +0 -8
  33. data/spec/active_record/locking_extensions_spec.rb +0 -110
  34. data/spec/double_entry/account_balance_spec.rb +0 -7
  35. data/spec/double_entry/account_spec.rb +0 -130
  36. data/spec/double_entry/balance_calculator_spec.rb +0 -88
  37. data/spec/double_entry/configuration_spec.rb +0 -50
  38. data/spec/double_entry/line_spec.rb +0 -80
  39. data/spec/double_entry/locking_spec.rb +0 -214
  40. data/spec/double_entry/performance/double_entry_performance_spec.rb +0 -32
  41. data/spec/double_entry/performance/reporting/aggregate_performance_spec.rb +0 -50
  42. data/spec/double_entry/reporting/aggregate_array_spec.rb +0 -123
  43. data/spec/double_entry/reporting/aggregate_spec.rb +0 -205
  44. data/spec/double_entry/reporting/line_aggregate_filter_spec.rb +0 -90
  45. data/spec/double_entry/reporting/line_aggregate_spec.rb +0 -39
  46. data/spec/double_entry/reporting/month_range_spec.rb +0 -139
  47. data/spec/double_entry/reporting/time_range_array_spec.rb +0 -169
  48. data/spec/double_entry/reporting/time_range_spec.rb +0 -45
  49. data/spec/double_entry/reporting/week_range_spec.rb +0 -103
  50. data/spec/double_entry/reporting_spec.rb +0 -181
  51. data/spec/double_entry/transfer_spec.rb +0 -93
  52. data/spec/double_entry/validation/line_check_spec.rb +0 -99
  53. data/spec/double_entry_spec.rb +0 -428
  54. data/spec/generators/double_entry/install/install_generator_spec.rb +0 -30
  55. data/spec/spec_helper.rb +0 -118
  56. data/spec/support/accounts.rb +0 -21
  57. data/spec/support/blueprints.rb +0 -43
  58. data/spec/support/database.example.yml +0 -21
  59. data/spec/support/database.travis.yml +0 -24
  60. data/spec/support/double_entry_spec_helper.rb +0 -27
  61. data/spec/support/gemfiles/Gemfile.rails-3.2.x +0 -8
  62. data/spec/support/gemfiles/Gemfile.rails-4.1.x +0 -6
  63. data/spec/support/gemfiles/Gemfile.rails-4.2.x +0 -5
  64. data/spec/support/gemfiles/Gemfile.rails-5.0.x +0 -5
  65. data/spec/support/performance_helper.rb +0 -26
  66. data/spec/support/reporting_configuration.rb +0 -6
  67. data/spec/support/schema.rb +0 -74
@@ -2,21 +2,29 @@
2
2
  module DoubleEntry
3
3
  module Reporting
4
4
  class Aggregate
5
- attr_reader :function, :account, :code, :range, :filter, :currency
5
+ attr_reader :function, :account, :partner_account, :code, :range, :filter, :currency
6
6
 
7
- def self.formatted_amount(function, account, code, range, options = {})
8
- new(function, account, code, range, options).formatted_amount
7
+ def self.formatted_amount(function:, account:, code:, range:, partner_account: nil, filter: nil)
8
+ new(
9
+ function: function,
10
+ account: account,
11
+ code: code,
12
+ range: range,
13
+ partner_account: partner_account,
14
+ filter: filter,
15
+ ).formatted_amount
9
16
  end
10
17
 
11
- def initialize(function, account, code, range, options = {})
18
+ def initialize(function:, account:, code:, range:, partner_account: nil, filter: nil)
12
19
  @function = function.to_s
13
20
  fail AggregateFunctionNotSupported unless %w(sum count average).include?(@function)
14
21
 
15
- @account = account
16
- @code = code ? code.to_s : nil
17
- @range = range
18
- @filter = options[:filter]
19
- @currency = DoubleEntry::Account.currency(account)
22
+ @account = account
23
+ @code = code.try(:to_s)
24
+ @range = range
25
+ @partner_account = partner_account
26
+ @filter = filter
27
+ @currency = DoubleEntry::Account.currency(account)
20
28
  end
21
29
 
22
30
  def amount(force_recalculation = false)
@@ -52,7 +60,14 @@ module DoubleEntry
52
60
  if range.class == YearRange
53
61
  aggregate = calculate_yearly_aggregate
54
62
  else
55
- aggregate = LineAggregate.aggregate(function, account, code, range, filter)
63
+ aggregate = LineAggregate.aggregate(
64
+ function: function,
65
+ account: account,
66
+ partner_account: partner_account,
67
+ code: code,
68
+ range: range,
69
+ named_scopes: filter
70
+ )
56
71
  end
57
72
 
58
73
  if range_is_complete?
@@ -73,7 +88,9 @@ module DoubleEntry
73
88
  calculate_yearly_average
74
89
  else
75
90
  result = (1..12).inject(formatted_amount(0)) do |total, month|
76
- total + Aggregate.new(function, account, code, MonthRange.new(:year => range.year, :month => month), :filter => filter).formatted_amount
91
+ total + Aggregate.new(function: function, account: account, code: code,
92
+ range: MonthRange.new(:year => range.year, :month => month),
93
+ partner_account: partner_account, filter: filter).formatted_amount
77
94
  end
78
95
  result.is_a?(Money) ? result.cents : result
79
96
  end
@@ -82,8 +99,10 @@ module DoubleEntry
82
99
  def calculate_yearly_average
83
100
  # need this seperate function, because an average of averages is not the correct average
84
101
  year_range = YearRange.new(:year => range.year)
85
- sum = Aggregate.new(:sum, account, code, year_range, :filter => filter).formatted_amount
86
- count = Aggregate.new(:count, account, code, year_range, :filter => filter).formatted_amount
102
+ sum = Aggregate.new(function: :sum, account: account, code: code, range: year_range,
103
+ partner_account: partner_account, filter: filter).formatted_amount
104
+ count = Aggregate.new(function: :count, account: account, code: code, range: year_range,
105
+ partner_account: partner_account, filter: filter).formatted_amount
87
106
  (count == 0) ? 0 : (sum / count).cents
88
107
  end
89
108
 
@@ -93,16 +112,17 @@ module DoubleEntry
93
112
 
94
113
  def field_hash
95
114
  {
96
- :function => function,
97
- :account => account,
98
- :code => code,
99
- :year => range.year,
100
- :month => range.month,
101
- :week => range.week,
102
- :day => range.day,
103
- :hour => range.hour,
104
- :filter => filter.inspect,
105
- :range_type => range.range_type.to_s,
115
+ :function => function,
116
+ :account => account,
117
+ :partner_account => partner_account,
118
+ :code => code,
119
+ :year => range.year,
120
+ :month => range.month,
121
+ :week => range.week,
122
+ :day => range.day,
123
+ :hour => range.hour,
124
+ :filter => filter.inspect,
125
+ :range_type => range.range_type.to_s,
106
126
  }
107
127
  end
108
128
  end
@@ -9,17 +9,18 @@ module DoubleEntry
9
9
  #
10
10
  # For example, you could request all sales
11
11
  # broken down by month and it would return an array of values
12
- attr_reader :function, :account, :code, :filter, :range_type, :start, :finish, :currency
12
+ attr_reader :function, :account, :partner_account, :code, :filter, :range_type, :start, :finish, :currency
13
13
 
14
- def initialize(function, account, code, options)
15
- @function = function.to_s
16
- @account = account
17
- @code = code
18
- @filter = options[:filter]
19
- @range_type = options[:range_type]
20
- @start = options[:start]
21
- @finish = options[:finish]
22
- @currency = DoubleEntry::Account.currency(account)
14
+ def initialize(function:, account:, code:, partner_account: nil, filter: nil, range_type: nil, start: nil, finish: nil)
15
+ @function = function.to_s
16
+ @account = account
17
+ @code = code
18
+ @partner_account = partner_account
19
+ @filter = filter
20
+ @range_type = range_type
21
+ @start = start
22
+ @finish = finish
23
+ @currency = DoubleEntry::Account.currency(account)
23
24
 
24
25
  retrieve_aggregates
25
26
  fill_in_missing_aggregates
@@ -39,7 +40,8 @@ module DoubleEntry
39
40
  # (this includes aggregates for the still-running period)
40
41
  all_periods.each do |period|
41
42
  unless @aggregates[period.key]
42
- @aggregates[period.key] = Aggregate.formatted_amount(function, account, code, period, :filter => filter)
43
+ @aggregates[period.key] = Aggregate.formatted_amount(function: function, account: account, code: code,
44
+ range: period, partner_account: partner_account, filter: filter)
43
45
  end
44
46
  end
45
47
  end
@@ -50,8 +52,9 @@ module DoubleEntry
50
52
  scope = LineAggregate.
51
53
  where(:function => function).
52
54
  where(:range_type => 'normal').
53
- where(:account => account.to_s).
54
- where(:code => code.to_s).
55
+ where(:account => account.try(:to_s)).
56
+ where(:partner_account => partner_account.try(:to_s)).
57
+ where(:code => code.try(:to_s)).
55
58
  where(:filter => filter.inspect).
56
59
  where(LineAggregate.arel_table[range_type].not_eq(nil))
57
60
  @aggregates = scope.each_with_object({}) do |result, hash|
@@ -2,8 +2,9 @@
2
2
  module DoubleEntry
3
3
  module Reporting
4
4
  class LineAggregate < ActiveRecord::Base
5
- def self.aggregate(function, account, code, range, named_scopes)
6
- collection_filter = LineAggregateFilter.new(account, code, range, named_scopes)
5
+ def self.aggregate(function:, account:, partner_account:, code:, range:, named_scopes:)
6
+ collection_filter = LineAggregateFilter.new(account: account, partner_account: partner_account,
7
+ code: code, range: range, filter_criteria: named_scopes)
7
8
  collection = collection_filter.filter
8
9
  collection.send(function, :amount)
9
10
  end
@@ -2,8 +2,10 @@
2
2
  module DoubleEntry
3
3
  module Reporting
4
4
  class LineAggregateFilter
5
- def initialize(account, code, range, filter_criteria)
5
+
6
+ def initialize(account:, partner_account:, code:, range:, filter_criteria:)
6
7
  @account = account
8
+ @partner_account = partner_account
7
9
  @code = code
8
10
  @range = range
9
11
  @filter_criteria = filter_criteria || []
@@ -20,6 +22,7 @@ module DoubleEntry
20
22
  where(:account => @account).
21
23
  where(:created_at => @range.start..@range.finish)
22
24
  collection = collection.where(:code => @code) if @code
25
+ collection = collection.where(:partner_account => @partner_account) if @partner_account
23
26
 
24
27
  collection
25
28
  end
@@ -42,10 +45,11 @@ module DoubleEntry
42
45
  # :name => :ten_dollar_purchases
43
46
  # }
44
47
  # },
45
- # # an example of providing a single metadatum criteria to filter on
48
+ # # an example of providing multiple metadatum criteria to filter on
46
49
  # {
47
50
  # :metadata => {
48
- # :meme => :business_cat
51
+ # :meme => :business_cat,
52
+ # :category => :fun_times,
49
53
  # }
50
54
  # }
51
55
  # ]
@@ -66,13 +70,7 @@ module DoubleEntry
66
70
  end
67
71
 
68
72
  def filter_by_metadata(collection, metadata)
69
- metadata.reduce(collection.joins(:metadata)) do |filtered_collection, (key, value)|
70
- filtered_collection.where(metadata_table => { :key => key, :value => value })
71
- end
72
- end
73
-
74
- def metadata_table
75
- DoubleEntry::LineMetadata.table_name.to_sym
73
+ DoubleEntry::Reporting::LineMetadataFilter.filter(collection: collection, metadata: metadata)
76
74
  end
77
75
  end
78
76
  end
@@ -0,0 +1,33 @@
1
+ # encoding: utf-8
2
+ module DoubleEntry
3
+ module Reporting
4
+ class LineMetadataFilter
5
+
6
+ def self.filter(collection:, metadata:)
7
+ table_alias_index = 0
8
+
9
+ metadata.reduce(collection) do |filtered_collection, (key, value)|
10
+ table_alias = "m#{table_alias_index}"
11
+ table_alias_index += 1
12
+
13
+ filtered_collection.
14
+ joins("INNER JOIN #{line_metadata_table} as #{table_alias} ON #{table_alias}.line_id = #{lines_table}.id").
15
+ where("#{table_alias}.key = ? AND #{table_alias}.value = ?", key, value)
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def self.line_metadata_table
22
+ DoubleEntry::LineMetadata.table_name
23
+ end
24
+ private_class_method :line_metadata_table
25
+
26
+ def self.lines_table
27
+ DoubleEntry::Line.table_name
28
+ end
29
+ private_class_method :lines_table
30
+
31
+ end
32
+ end
33
+ end
@@ -1,19 +1,17 @@
1
1
  # encoding: utf-8
2
+ require 'forwardable'
3
+
2
4
  module DoubleEntry
3
5
  class Transfer
4
6
  class << self
5
- attr_writer :transfers, :code_max_length
7
+ attr_accessor :code_max_length
8
+ attr_writer :transfers
6
9
 
7
10
  # @api private
8
11
  def transfers
9
12
  @transfers ||= Set.new
10
13
  end
11
14
 
12
- # @api private
13
- def code_max_length
14
- @code_max_length ||= 47
15
- end
16
-
17
15
  # @api private
18
16
  def transfer(amount, options = {})
19
17
  fail TransferIsNegative if amount.negative?
@@ -25,37 +23,43 @@ module DoubleEntry
25
23
  end
26
24
 
27
25
  # @api private
28
- class Set < Array
26
+ class Set
27
+ extend Forwardable
28
+ delegate [:each, :map] => :all
29
+
29
30
  def define(attributes)
30
- self << Transfer.new(attributes)
31
+ Transfer.new(attributes).tap do |transfer|
32
+ key = [transfer.from, transfer.to, transfer.code]
33
+ if _find(*key)
34
+ fail DuplicateTransfer
35
+ else
36
+ backing_collection[key] = transfer
37
+ end
38
+ end
31
39
  end
32
40
 
33
- def find(from, to, code)
34
- _find(from.identifier, to.identifier, code)
41
+ def find(from_account, to_account, code)
42
+ _find(from_account.identifier, to_account.identifier, code)
35
43
  end
36
44
 
37
- def find!(from, to, code)
38
- find(from, to, code).tap do |transfer|
39
- fail TransferNotAllowed, [from.identifier, to.identifier, code].inspect unless transfer
45
+ def find!(from_account, to_account, code)
46
+ find(from_account, to_account, code).tap do |transfer|
47
+ fail TransferNotAllowed, [from_account.identifier, to_account.identifier, code].inspect unless transfer
40
48
  end
41
49
  end
42
50
 
43
- def <<(transfer)
44
- if _find(transfer.from, transfer.to, transfer.code)
45
- fail DuplicateTransfer
46
- else
47
- super(transfer)
48
- end
51
+ def all
52
+ backing_collection.values
49
53
  end
50
54
 
51
55
  private
52
56
 
57
+ def backing_collection
58
+ @backing_collection ||= Hash.new
59
+ end
60
+
53
61
  def _find(from, to, code)
54
- detect do |transfer|
55
- transfer.from == from &&
56
- transfer.to == to &&
57
- transfer.code == code
58
- end
62
+ backing_collection[[from, to, code]]
59
63
  end
60
64
  end
61
65
 
@@ -65,7 +69,7 @@ module DoubleEntry
65
69
  @code = attributes[:code]
66
70
  @from = attributes[:from]
67
71
  @to = attributes[:to]
68
- if code.length > Transfer.code_max_length
72
+ if Transfer.code_max_length && code.length > Transfer.code_max_length
69
73
  fail TransferCodeTooLongError,
70
74
  "transfer code '#{code}' is too long. Please limit it to #{Transfer.code_max_length} characters."
71
75
  end
@@ -115,8 +119,10 @@ module DoubleEntry
115
119
 
116
120
  def create_line_metadata(credit, debit, metadata)
117
121
  metadata.each_pair do |key, value|
118
- LineMetadata.create!(:line => credit, :key => key, :value => value)
119
- LineMetadata.create!(:line => debit, :key => key, :value => value)
122
+ Array(value).each do |each_value|
123
+ LineMetadata.create!(:line => credit, :key => key, :value => each_value)
124
+ LineMetadata.create!(:line => debit, :key => key, :value => each_value)
125
+ end
120
126
  end
121
127
  end
122
128
  end
@@ -1 +1,2 @@
1
+ require 'double_entry/validation/account_fixer'
1
2
  require 'double_entry/validation/line_check'
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DoubleEntry
4
+ module Validation
5
+ class AccountFixer
6
+ def recalculate_account(account)
7
+ DoubleEntry.lock_accounts(account) do
8
+ recalculated_balance = Money.zero(account.currency)
9
+
10
+ lines_for_account(account).each do |line|
11
+ recalculated_balance += line.amount
12
+ if line.balance != recalculated_balance
13
+ line.update_attribute(:balance, recalculated_balance)
14
+ end
15
+ end
16
+
17
+ update_balance_for_account(account, recalculated_balance)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def lines_for_account(account)
24
+ Line.where(
25
+ account: account.identifier.to_s,
26
+ scope: account.scope_identity.to_s
27
+ ).order(:id)
28
+ end
29
+
30
+ def update_balance_for_account(account, balance)
31
+ account_balance = Locking.balance_for_locked_account(account)
32
+ account_balance.update_attribute(:balance, balance)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -4,13 +4,16 @@ require 'set'
4
4
  module DoubleEntry
5
5
  module Validation
6
6
  class LineCheck < ActiveRecord::Base
7
- default_scope -> { order('created_at') }
8
7
 
9
- def self.perform!
10
- new.perform
8
+ def self.last_line_id_checked
9
+ order('created_at DESC').limit(1).pluck(:last_line_id).first || 0
11
10
  end
12
11
 
13
- def perform
12
+ def self.perform!(fixer: nil)
13
+ new.perform(fixer: fixer)
14
+ end
15
+
16
+ def perform(fixer: nil)
14
17
  log = ''
15
18
  current_line_id = nil
16
19
 
@@ -24,10 +27,10 @@ module DoubleEntry
24
27
  end
25
28
 
26
29
  active_accounts.each do |account|
27
- incorrect_accounts << account unless cached_balance_correct?(account)
30
+ incorrect_accounts << account unless cached_balance_correct?(account, log)
28
31
  end
29
32
 
30
- incorrect_accounts.each { |account| recalculate_account(account) }
33
+ incorrect_accounts.each(&fixer.method(:recalculate_account)) if fixer
31
34
 
32
35
  unless active_accounts.empty?
33
36
  LineCheck.create!(
@@ -40,13 +43,8 @@ module DoubleEntry
40
43
 
41
44
  private
42
45
 
43
- def last_run_line_id
44
- latest = LineCheck.last
45
- latest ? latest.last_line_id : 0
46
- end
47
-
48
46
  def new_lines_since_last_run
49
- Line.where('id > ?', last_run_line_id)
47
+ Line.with_id_greater_than(LineCheck.last_line_id_checked)
50
48
  end
51
49
 
52
50
  def running_balance_correct?(line, log)
@@ -88,39 +86,23 @@ module DoubleEntry
88
86
  #{previous_line.inspect}
89
87
  #{line.inspect}
90
88
 
91
- END_OF_MESSAGE
92
- end
93
-
94
- def cached_balance_correct?(account)
95
- DoubleEntry.lock_accounts(account) do
96
- return AccountBalance.find_by_account(account).balance == account.balance
97
- end
89
+ END_OF_MESSAGE
98
90
  end
99
91
 
100
- def recalculate_account(account)
92
+ def cached_balance_correct?(account, log)
101
93
  DoubleEntry.lock_accounts(account) do
102
- recalculated_balance = Money.zero(account.currency)
103
-
104
- lines_for_account(account).each do |line|
105
- recalculated_balance += line.amount
106
- line.update_attribute(:balance, recalculated_balance) if line.balance != recalculated_balance
107
- end
108
-
109
- update_balance_for_account(account, recalculated_balance)
94
+ cached_balance = AccountBalance.find_by_account(account).balance
95
+ running_balance = account.balance
96
+ correct = (cached_balance == running_balance)
97
+ log << <<~MESSAGE unless correct
98
+ *********************************
99
+ Error on account #{account}: #{cached_balance} (cached balance) != #{running_balance} (running balance)
100
+ *********************************
101
+
102
+ MESSAGE
103
+ return correct
110
104
  end
111
105
  end
112
-
113
- def lines_for_account(account)
114
- Line.where(
115
- :account => account.identifier.to_s,
116
- :scope => account.scope_identity.to_s,
117
- ).order(:id)
118
- end
119
-
120
- def update_balance_for_account(account, balance)
121
- account_balance = Locking.balance_for_locked_account(account)
122
- account_balance.update_attribute(:balance, balance)
123
- end
124
106
  end
125
107
  end
126
108
  end